diff --git a/.env.example b/.env.example index a72dbd1b..6823a9d0 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ CORS_ORIGINS=http://localhost:3000 DB_AUTO_MIGRATE=true LOG_LEVEL=INFO REQUEST_LOG_SLOW_MS=1000 +AUTH_MODE=local +# REQUIRED when AUTH_MODE=local (must be non-placeholder and at least 50 chars). +LOCAL_AUTH_TOKEN= # --- frontend settings --- # REQUIRED: Public URL used by the browser to reach the API. diff --git a/README.md b/README.md index 615cc27a..11b4272a 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,20 @@ Operational deep dives: - Production notes: [Production notes](./docs/production/README.md) - Troubleshooting: [Troubleshooting](./docs/troubleshooting/README.md) -## Authentication (Clerk) +## Authentication -**Clerk is currently required**. +Mission Control supports two auth modes via `AUTH_MODE`: -You must configure Clerk keys for: -- the frontend (`NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`, `CLERK_SECRET_KEY`) -- the backend (`CLERK_SECRET_KEY`) +- `local`: shared bearer token auth for self-hosted deployments +- `clerk`: Clerk JWT auth -See: [Deployment guide](./docs/deployment/README.md#clerk-auth-notes). +`local` mode requires: +- backend: `AUTH_MODE=local`, `LOCAL_AUTH_TOKEN=` +- frontend: `NEXT_PUBLIC_AUTH_MODE=local`, then enter the token in the login screen + +`clerk` mode requires: +- backend: `AUTH_MODE=clerk`, `CLERK_SECRET_KEY=` +- frontend: `NEXT_PUBLIC_AUTH_MODE=clerk`, `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=` ## Deployment modes @@ -49,12 +54,16 @@ See: [Deployment guide](./docs/deployment/README.md#clerk-auth-notes). ```bash cp .env.example .env +# REQUIRED for local auth mode: +# set LOCAL_AUTH_TOKEN to a non-placeholder value with at least 50 characters. + # REQUIRED: the browser must be able to reach the backend. # NEXT_PUBLIC_API_URL must be reachable from the *browser* (host), not an internal Docker network name. # Missing/blank NEXT_PUBLIC_API_URL will break frontend API calls (e.g. Activity feed). -# REQUIRED: Clerk config. -# Provide real Clerk values via frontend/.env (recommended) and backend/.env. +# Auth defaults in .env.example are local mode. +# For production, set LOCAL_AUTH_TOKEN to a random value with at least 50 characters. +# For Clerk mode, set AUTH_MODE=clerk and provide Clerk keys. docker compose -f compose.yml --env-file .env up -d --build ``` diff --git a/backend/.env.example b/backend/.env.example index e2c65f26..8d3f3dec 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,8 +8,13 @@ DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_contr CORS_ORIGINS=http://localhost:3000 BASE_URL= -# Clerk (auth only) -CLERK_SECRET_KEY=sk_test_your_clerk_secret_key +# Auth mode: clerk or local. +AUTH_MODE=local +# REQUIRED when AUTH_MODE=local (must be non-placeholder and at least 50 chars). +LOCAL_AUTH_TOKEN= + +# Clerk (auth only; used when AUTH_MODE=clerk) +CLERK_SECRET_KEY= CLERK_API_URL=https://api.clerk.com CLERK_VERIFY_IAT=true CLERK_LEEWAY=10.0 diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 273f53cf..14decaf9 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -1,8 +1,9 @@ -"""User authentication helpers backed by Clerk JWT verification.""" +"""User authentication helpers for Clerk and local-token auth modes.""" from __future__ import annotations from dataclasses import dataclass +from hmac import compare_digest from typing import TYPE_CHECKING, Literal import httpx @@ -15,6 +16,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, ValidationError from starlette.concurrency import run_in_threadpool +from app.core.auth_mode import AuthMode from app.core.config import settings from app.core.logging import get_logger from app.db import crud @@ -29,6 +31,9 @@ logger = get_logger(__name__) security = HTTPBearer(auto_error=False) SECURITY_DEP = Depends(security) SESSION_DEP = Depends(get_session) +LOCAL_AUTH_USER_ID = "local-auth-user" +LOCAL_AUTH_EMAIL = "admin@home.local" +LOCAL_AUTH_NAME = "Local User" class ClerkTokenPayload(BaseModel): @@ -45,6 +50,18 @@ class AuthContext: user: User | None = None +def _extract_bearer_token(authorization: str | None) -> str | None: + if not authorization: + return None + value = authorization.strip() + if not value: + return None + if not value.lower().startswith("bearer "): + return None + token = value.split(" ", maxsplit=1)[1].strip() + return token or None + + def _non_empty_str(value: object) -> str | None: if not isinstance(value, str): return None @@ -228,6 +245,9 @@ async def _fetch_clerk_profile(clerk_user_id: str) -> tuple[str | None, str | No async def delete_clerk_user(clerk_user_id: str) -> None: """Delete a Clerk user via the official Clerk SDK.""" + if settings.auth_mode != AuthMode.CLERK: + return + secret = settings.clerk_secret_key.strip() secret_kind = secret.split("_", maxsplit=1)[0] if "_" in secret else "unknown" server_url = _normalize_clerk_server_url(settings.clerk_api_url or "") @@ -343,6 +363,55 @@ async def _get_or_sync_user( return user +async def _get_or_create_local_user(session: AsyncSession) -> User: + defaults: dict[str, object] = { + "email": LOCAL_AUTH_EMAIL, + "name": LOCAL_AUTH_NAME, + } + user, _created = await crud.get_or_create( + session, + User, + clerk_user_id=LOCAL_AUTH_USER_ID, + defaults=defaults, + ) + changed = False + if not user.email: + user.email = LOCAL_AUTH_EMAIL + changed = True + if not user.name: + user.name = LOCAL_AUTH_NAME + changed = True + if changed: + session.add(user) + await session.commit() + await session.refresh(user) + + from app.services.organizations import ensure_member_for_user + + await ensure_member_for_user(session, user) + return user + + +async def _resolve_local_auth_context( + *, + request: Request, + session: AsyncSession, + required: bool, +) -> AuthContext | None: + token = _extract_bearer_token(request.headers.get("Authorization")) + if token is None: + if required: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + return None + expected = settings.local_auth_token.strip() + if not expected or not compare_digest(token, expected): + if required: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + return None + user = await _get_or_create_local_user(session) + return AuthContext(actor_type="user", user=user) + + def _parse_subject(claims: dict[str, object]) -> str | None: payload = ClerkTokenPayload.model_validate(claims) return payload.sub @@ -353,7 +422,17 @@ async def get_auth_context( credentials: HTTPAuthorizationCredentials | None = SECURITY_DEP, session: AsyncSession = SESSION_DEP, ) -> AuthContext: - """Resolve required authenticated user context from Clerk JWT headers.""" + """Resolve required authenticated user context for the configured auth mode.""" + if settings.auth_mode == AuthMode.LOCAL: + local_auth = await _resolve_local_auth_context( + request=request, + session=session, + required=True, + ) + if local_auth is None: # pragma: no cover + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + return local_auth + request_state = await _authenticate_clerk_request(request) if request_state.status != AuthStatus.SIGNED_IN or not isinstance(request_state.payload, dict): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) @@ -388,6 +467,13 @@ async def get_auth_context_optional( """Resolve user context if available, otherwise return `None`.""" if request.headers.get("X-Agent-Token"): return None + if settings.auth_mode == AuthMode.LOCAL: + return await _resolve_local_auth_context( + request=request, + session=session, + required=False, + ) + request_state = await _authenticate_clerk_request(request) if request_state.status != AuthStatus.SIGNED_IN or not isinstance(request_state.payload, dict): return None diff --git a/backend/app/core/auth_mode.py b/backend/app/core/auth_mode.py new file mode 100644 index 00000000..c2687fb5 --- /dev/null +++ b/backend/app/core/auth_mode.py @@ -0,0 +1,12 @@ +"""Shared auth-mode enum values.""" + +from __future__ import annotations + +from enum import Enum + + +class AuthMode(str, Enum): + """Supported authentication modes for backend and frontend.""" + + CLERK = "clerk" + LOCAL = "local" diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b563ca14..d857e39e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -8,8 +8,19 @@ from typing import Self from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from app.core.auth_mode import AuthMode + BACKEND_ROOT = Path(__file__).resolve().parents[2] DEFAULT_ENV_FILE = BACKEND_ROOT / ".env" +LOCAL_AUTH_TOKEN_MIN_LENGTH = 50 +LOCAL_AUTH_TOKEN_PLACEHOLDERS = frozenset( + { + "change-me", + "changeme", + "replace-me", + "replace-with-strong-random-token", + }, +) class Settings(BaseSettings): @@ -26,8 +37,12 @@ class Settings(BaseSettings): environment: str = "dev" database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/openclaw_agency" + # Auth mode: "clerk" for Clerk JWT auth, "local" for shared bearer token auth. + auth_mode: AuthMode + local_auth_token: str = "" + # Clerk auth (auth only; roles stored in DB) - clerk_secret_key: str = Field(min_length=1) + clerk_secret_key: str = "" clerk_api_url: str = "https://api.clerk.com" clerk_verify_iat: bool = True clerk_leeway: float = 10.0 @@ -47,8 +62,21 @@ class Settings(BaseSettings): @model_validator(mode="after") def _defaults(self) -> Self: - if not self.clerk_secret_key.strip(): - raise ValueError("CLERK_SECRET_KEY must be set and non-empty.") + if self.auth_mode == AuthMode.CLERK: + if not self.clerk_secret_key.strip(): + raise ValueError( + "CLERK_SECRET_KEY must be set and non-empty when AUTH_MODE=clerk.", + ) + elif self.auth_mode == AuthMode.LOCAL: + token = self.local_auth_token.strip() + if ( + not token + or len(token) < LOCAL_AUTH_TOKEN_MIN_LENGTH + or token.lower() in LOCAL_AUTH_TOKEN_PLACEHOLDERS + ): + raise ValueError( + "LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local.", + ) # In dev, default to applying Alembic migrations at startup to avoid # schema drift (e.g. missing newly-added columns). if "db_auto_migrate" not in self.model_fields_set and self.environment == "dev": diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 80ba349f..e522fc29 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,9 +1,15 @@ # ruff: noqa: INP001 """Pytest configuration shared across backend tests.""" +import os import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) + +# Tests should fail fast if auth-mode wiring breaks, but still need deterministic +# defaults during import-time settings initialization, regardless of shell env. +os.environ["AUTH_MODE"] = "local" +os.environ["LOCAL_AUTH_TOKEN"] = "test-local-token-0123456789-0123456789-0123456789x" diff --git a/backend/tests/test_authenticate_request_flow.py b/backend/tests/test_authenticate_request_flow.py index 130bf1ff..d0fcde68 100644 --- a/backend/tests/test_authenticate_request_flow.py +++ b/backend/tests/test_authenticate_request_flow.py @@ -9,6 +9,7 @@ import pytest from fastapi import HTTPException from app.core import auth +from app.core.auth_mode import AuthMode from app.models.users import User @@ -21,6 +22,9 @@ class _FakeSession: async def test_get_auth_context_raises_401_when_clerk_signed_out( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setattr(auth.settings, "auth_mode", AuthMode.CLERK) + monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy") + from clerk_backend_api.security.types import AuthStatus, RequestState async def _fake_authenticate(_request: Any) -> RequestState: @@ -42,6 +46,9 @@ async def test_get_auth_context_raises_401_when_clerk_signed_out( async def test_get_auth_context_uses_request_state_payload_claims( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setattr(auth.settings, "auth_mode", AuthMode.CLERK) + monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy") + from clerk_backend_api.security.types import AuthStatus, RequestState async def _fake_authenticate(_request: Any) -> RequestState: @@ -82,6 +89,9 @@ async def test_get_auth_context_uses_request_state_payload_claims( async def test_get_auth_context_optional_returns_none_for_agent_token( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setattr(auth.settings, "auth_mode", AuthMode.CLERK) + monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy") + async def _boom(_request: Any) -> Any: # pragma: no cover raise AssertionError("_authenticate_clerk_request should not be called") @@ -93,3 +103,46 @@ async def test_get_auth_context_optional_returns_none_for_agent_token( session=_FakeSession(), # type: ignore[arg-type] ) assert out is None + + +@pytest.mark.asyncio +async def test_get_auth_context_local_mode_requires_valid_bearer_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(auth.settings, "auth_mode", AuthMode.LOCAL) + monkeypatch.setattr(auth.settings, "local_auth_token", "expected-token") + + async def _fake_local_user(_session: Any) -> User: + return User(clerk_user_id="local-auth-user", email="local@localhost", name="Local User") + + monkeypatch.setattr(auth, "_get_or_create_local_user", _fake_local_user) + + ctx = await auth.get_auth_context( # type: ignore[arg-type] + request=SimpleNamespace(headers={"Authorization": "Bearer expected-token"}), + credentials=None, + session=_FakeSession(), # type: ignore[arg-type] + ) + + assert ctx.actor_type == "user" + assert ctx.user is not None + assert ctx.user.clerk_user_id == "local-auth-user" + + +@pytest.mark.asyncio +async def test_get_auth_context_optional_local_mode_returns_none_without_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(auth.settings, "auth_mode", AuthMode.LOCAL) + monkeypatch.setattr(auth.settings, "local_auth_token", "expected-token") + + async def _boom(_session: Any) -> User: # pragma: no cover + raise AssertionError("_get_or_create_local_user should not be called") + + monkeypatch.setattr(auth, "_get_or_create_local_user", _boom) + + out = await auth.get_auth_context_optional( # type: ignore[arg-type] + request=SimpleNamespace(headers={}), + credentials=None, + session=_FakeSession(), # type: ignore[arg-type] + ) + assert out is None diff --git a/backend/tests/test_config_auth_mode.py b/backend/tests/test_config_auth_mode.py new file mode 100644 index 00000000..1f913302 --- /dev/null +++ b/backend/tests/test_config_auth_mode.py @@ -0,0 +1,70 @@ +# ruff: noqa: INP001 +"""Settings validation tests for auth-mode configuration.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.core.auth_mode import AuthMode +from app.core.config import Settings + + +def test_local_mode_requires_non_empty_token() -> None: + with pytest.raises( + ValidationError, + match="LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local", + ): + Settings( + _env_file=None, + auth_mode=AuthMode.LOCAL, + local_auth_token="", + ) + + +def test_local_mode_requires_minimum_length() -> None: + with pytest.raises( + ValidationError, + match="LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local", + ): + Settings( + _env_file=None, + auth_mode=AuthMode.LOCAL, + local_auth_token="x" * 49, + ) + + +def test_local_mode_rejects_placeholder_token() -> None: + with pytest.raises( + ValidationError, + match="LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local", + ): + Settings( + _env_file=None, + auth_mode=AuthMode.LOCAL, + local_auth_token="change-me", + ) + + +def test_local_mode_accepts_real_token() -> None: + token = "a" * 50 + settings = Settings( + _env_file=None, + auth_mode=AuthMode.LOCAL, + local_auth_token=token, + ) + + assert settings.auth_mode == AuthMode.LOCAL + assert settings.local_auth_token == token + + +def test_clerk_mode_requires_secret_key() -> None: + with pytest.raises( + ValidationError, + match="CLERK_SECRET_KEY must be set and non-empty when AUTH_MODE=clerk", + ): + Settings( + _env_file=None, + auth_mode=AuthMode.CLERK, + clerk_secret_key="", + ) diff --git a/backend/tests/test_local_auth_integration.py b/backend/tests/test_local_auth_integration.py new file mode 100644 index 00000000..79e30f6b --- /dev/null +++ b/backend/tests/test_local_auth_integration.py @@ -0,0 +1,100 @@ +# ruff: noqa: INP001 +"""Integration tests for local auth mode on protected API routes.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from fastapi import APIRouter, FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine +from sqlmodel import SQLModel +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.api.users import router as users_router +from app.core import auth as auth_module +from app.core.auth_mode import AuthMode +from app.core.config import settings +from app.db.session import get_session + + +async def _make_engine() -> AsyncEngine: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.connect() as conn, conn.begin(): + await conn.run_sync(SQLModel.metadata.create_all) + return engine + + +def _build_test_app( + session_maker: async_sessionmaker[AsyncSession], +) -> FastAPI: + app = FastAPI() + api_v1 = APIRouter(prefix="/api/v1") + api_v1.include_router(users_router) + app.include_router(api_v1) + + async def _override_get_session() -> AsyncSession: + async with session_maker() as session: + yield session + + app.dependency_overrides[get_session] = _override_get_session + app.dependency_overrides[auth_module.get_session] = _override_get_session + return app + + +@pytest.mark.asyncio +async def test_local_auth_users_me_requires_and_accepts_valid_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + unique_suffix = uuid4().hex + expected_user_id = f"local-auth-integration-{unique_suffix}" + expected_email = f"local-{unique_suffix}@localhost" + expected_name = "Local Integration User" + + monkeypatch.setattr(settings, "auth_mode", AuthMode.LOCAL) + monkeypatch.setattr(settings, "local_auth_token", "integration-token") + monkeypatch.setattr(auth_module, "LOCAL_AUTH_USER_ID", expected_user_id) + monkeypatch.setattr(auth_module, "LOCAL_AUTH_EMAIL", expected_email) + monkeypatch.setattr(auth_module, "LOCAL_AUTH_NAME", expected_name) + + engine = await _make_engine() + session_maker = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + ) + app = _build_test_app(session_maker) + + try: + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + missing = await client.get("/api/v1/users/me") + assert missing.status_code == 401 + + invalid = await client.get( + "/api/v1/users/me", + headers={"Authorization": "Bearer wrong-token"}, + ) + assert invalid.status_code == 401 + + authorized = await client.get( + "/api/v1/users/me", + headers={"Authorization": "Bearer integration-token"}, + ) + assert authorized.status_code == 200 + payload = authorized.json() + assert payload["clerk_user_id"] == expected_user_id + assert payload["email"] == expected_email + assert payload["name"] == expected_name + + repeat = await client.get( + "/api/v1/users/me", + headers={"Authorization": "Bearer integration-token"}, + ) + assert repeat.status_code == 200 + assert repeat.json()["id"] == payload["id"] + finally: + await engine.dispose() diff --git a/compose.yml b/compose.yml index 4d046640..f3c85fd1 100644 --- a/compose.yml +++ b/compose.yml @@ -30,6 +30,8 @@ services: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-mission_control} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true} + AUTH_MODE: ${AUTH_MODE} + LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN} depends_on: db: condition: service_healthy @@ -41,6 +43,7 @@ services: context: ./frontend args: NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000} + NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE} # Optional, user-managed env file. # IMPORTANT: do NOT load `.env.example` here because it contains non-empty # placeholder Clerk keys, which can accidentally flip Clerk "on". @@ -49,6 +52,7 @@ services: required: false environment: NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000} + NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE} depends_on: - backend ports: diff --git a/docs/00-style-guide.md b/docs/00-style-guide.md new file mode 100644 index 00000000..c8a5419c --- /dev/null +++ b/docs/00-style-guide.md @@ -0,0 +1,96 @@ +# Documentation style guide + +This repository aims for a NetBox-like style: clear, technical, and written for working engineers. + +## Voice and tone + +- **Direct and technical.** Prefer short sentences and specific nouns. +- **Narrative flow.** Describe how the system behaves, not how the doc was produced. +- **Calm, professional tone.** Avoid hype. +- **Assume competence, not context.** Define repo-specific terms once, then reuse them. + +## Page structure (default) + +Use a consistent, scan-friendly layout. + +1. **Title** +2. **1–3 sentence intro** + - What this page covers and who it’s for. +3. **Deep dives / Related docs** (optional but common) + - Links to more detailed pages. +4. **Main content** + - Prefer sections that match user intent: “Quickstart”, “How it works”, “Configuration”, “Common workflows”, “Troubleshooting”. +5. **Next steps** (optional) + - Where to go next. + +## Headings and conventions + +- Prefer **verb-led** headings when describing procedures: “Run migrations”, “Regenerate the client”. +- Prefer **intent-led** headings when describing concepts: “How requests flow”, “Auth model”. +- Use numbered steps when order matters. +- Keep headings short; avoid long parentheticals. + +## Cross-linking + +- Treat the numbered IA pages in `docs/` as **entrypoints**. +- Link to deep dives instead of duplicating content. +- Use readable link text: + - Good: “Deployment guide” → `docs/deployment/README.md` + - Avoid: ``docs/deployment/README.md`` + +## Link formatting rules + +- Use markdown links: `[Deployment guide](deployment/README.md)`. +- Use relative paths that work in GitHub and typical markdown renderers. +- Keep code formatting for: + - commands (`make check`) + - environment variables (`NEXT_PUBLIC_API_URL`) + - literal file paths when you mean “this exact file on disk” (not as a navigational link) + +## Avoided phrases (and what to use instead) + +Avoid doc-meta language: + +- Avoid: “evidence basis”, “evidence anchors”, “this page is intentionally…” +- Prefer: + - “Source of truth: …” (only when it matters) + - “See also: …” + - Just link the file or section. + +Avoid hedging: + +- Avoid: “likely”, “probably”, “should” (unless it’s a policy decision) +- Prefer: state what the code does, and point to the file. + +## Preferred patterns + +- **Start here** blocks for role-based entry. +- **Common workflows** sections with copy/paste commands. +- **Troubleshooting** sections with symptoms → checks → fixes. +- **Footguns** called out explicitly when they can cause outages or confusing behavior. + +## Example rewrites + +### Example 1: remove doc-meta “evidence” language + +Before: +> Evidence basis: consolidated from repo root `README.md`, `.github/workflows/ci.yml`, `Makefile`. + +After: +> This page describes the development workflow that matches CI: setup, checks, and common local loops. + +### Example 2: prefer readable links over code-formatted paths + +Before: +- See `docs/deployment/README.md` for deployment. + +After: +- See the [Deployment guide](deployment/README.md). + +### Example 3: replace “first pass” filler with a clear scope boundary + +Before: +- Non-goals (first pass) + +After: +- Out of scope diff --git a/docs/05-architecture.md b/docs/05-architecture.md index 8ec794c8..d0056952 100644 --- a/docs/05-architecture.md +++ b/docs/05-architecture.md @@ -7,7 +7,7 @@ Mission Control is the **web UI + HTTP API** for operating OpenClaw. It’s where you manage boards, tasks, agents, approvals, and (optionally) gateway connections. -> Auth note: **Clerk is required for production**. The codebase includes gating so CI/local can run without “real” keys, but real deployments should configure Clerk. +> Auth note: Mission Control supports two auth modes: `local` (shared bearer token) and `clerk`. ## Components @@ -48,14 +48,17 @@ Common UI-driven data shapes: - “boards/tasks” views → board/task CRUD + streams. - “activity feed” → activity/events endpoints. -### 2) Authentication (Clerk) +### 2) Authentication (`local` or Clerk) -- **Frontend**: Clerk is enabled only when a publishable key is present/valid. - - Gating/wrappers: `frontend/src/auth/clerkKey.ts`, `frontend/src/auth/clerk.tsx`. -- **Frontend → backend**: API calls attach `Authorization: Bearer ` when available. - - Token injection: `frontend/src/api/mutator.ts` (uses `window.Clerk.session.getToken()`). -- **Backend**: validates inbound auth and resolves a user context. - - Implementation: `backend/app/core/auth.py` (uses `clerk_backend_api` SDK with `CLERK_SECRET_KEY`). +- **Frontend**: + - `local`: token entry + token storage (`frontend/src/components/organisms/LocalAuthLogin.tsx`, `frontend/src/auth/localAuth.ts`). + - `clerk`: Clerk wrappers/hooks (`frontend/src/auth/clerk.tsx`). +- **Frontend → backend**: + - API calls attach `Authorization: Bearer ` from local mode token or Clerk session token (`frontend/src/api/mutator.ts`). +- **Backend**: + - `local`: validates `LOCAL_AUTH_TOKEN`. + - `clerk`: validates Clerk request state via `clerk_backend_api` + `CLERK_SECRET_KEY`. + - Implementation: `backend/app/core/auth.py`. ### 3) Agent automation surface (`/api/v1/agent/*`) diff --git a/docs/06-configuration.md b/docs/06-configuration.md index 35237f96..d70184ff 100644 --- a/docs/06-configuration.md +++ b/docs/06-configuration.md @@ -68,12 +68,12 @@ Clerk: Template: `frontend/.env.example`. -- `NEXT_PUBLIC_API_URL` (required) - -Clerk: -- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` -- `CLERK_SECRET_KEY` -- redirect URLs (`NEXT_PUBLIC_CLERK_*`) +| Variable | Required? | Purpose | Default / example | Footguns | +|---|---:|---|---|---| +| `NEXT_PUBLIC_API_URL` | **yes** | Backend base URL used by the browser | `http://localhost:8000` | Must be browser-reachable | +| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | **yes** | Enables Clerk in the frontend | (none) | Must be a real publishable key | +| `NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL` | optional | Fallback redirect | `/boards` | — | +| `NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL` | optional | Post-logout redirect | `/` | — | ## Minimal dev configuration @@ -103,6 +103,11 @@ Evidence: `backend/app/main.py`, `backend/app/core/config.py`. - `CORS_ORIGINS` is a comma-separated list. - It must include the frontend origin (e.g. `http://localhost:3000`) or browser requests will fail. +## Common footguns + +- **Frontend env template vs runtime env**: `frontend/.env.example` is a template and `compose.yml` intentionally does **not** load it at runtime. Use user-managed `frontend/.env` (for Compose) or `frontend/.env.local` (for Next dev). +- **`NEXT_PUBLIC_API_URL` reachability**: must work from the browser’s network context (host), not only from within the Docker network. + ## Troubleshooting config issues - UI loads but API calls fail / Activity feed blank → `NEXT_PUBLIC_API_URL` is missing/incorrect. diff --git a/docs/07-api-reference.md b/docs/07-api-reference.md index 0c8ec4cb..29c18fa2 100644 --- a/docs/07-api-reference.md +++ b/docs/07-api-reference.md @@ -44,21 +44,56 @@ Evidence: `backend/app/main.py` includes routers from `backend/app/api/*`. |---|---|---| | `activity.py` | `/activity` | Activity listing and task-comment feed endpoints. | | `agent.py` | `/agent` | Agent-scoped API routes for board operations and gateway coordination. | -| `agents.py` | `/agents` | Agent lifecycle and streaming endpoints. | -| `approvals.py` | `/boards/{board_id}/approvals` | Approval list/create/update + streaming. | -| `auth.py` | `/auth` | Auth bootstrap endpoints. | -| `board_group_memory.py` | `/board-groups/{group_id}/memory` and `/boards/{board_id}/group-memory` | Board-group memory CRUD + streaming. | -| `board_groups.py` | `/board-groups` | Board group CRUD + snapshot + heartbeat apply. | -| `board_memory.py` | `/boards/{board_id}/memory` | Board memory CRUD + streaming. | -| `board_onboarding.py` | `/boards/{board_id}/onboarding` | Onboarding flows (user+agent). | -| `boards.py` | `/boards` | Board CRUD + snapshots. | -| `gateway.py` | `/gateways` | Gateway session inspection APIs (org admin). | -| `gateways.py` | `/gateways` | Gateway CRUD + templates sync (org admin). | -| `metrics.py` | `/metrics` | Dashboard metrics. | -| `organizations.py` | `/organizations` | Org + invites/membership flows. | -| `souls_directory.py` | `/souls-directory` | Search/fetch souls directory entries. | -| `tasks.py` | `/boards/{board_id}/tasks` | Task CRUD + comments + streaming. | -| `users.py` | `/users` | User self-service profile endpoints. | +| `agents.py` | `/agents` | Thin API wrappers for async agent lifecycle operations. | +| `approvals.py` | `/boards/{board_id}/approvals` | Approval listing, streaming, creation, and update endpoints. | +| `auth.py` | `/auth` | Authentication bootstrap endpoints for the Mission Control API. | +| `board_group_memory.py` | `/board-groups/{group_id}/memory` and `/boards/{board_id}/group-memory` | Board-group memory CRUD and streaming endpoints. | +| `board_groups.py` | `/board-groups` | Board group CRUD, snapshot, and heartbeat endpoints. | +| `board_memory.py` | `/boards/{board_id}/memory` | Board memory CRUD and streaming endpoints. | +| `board_onboarding.py` | `/boards/{board_id}/onboarding` | Board onboarding endpoints for user/agent collaboration. | +| `boards.py` | `/boards` | Board CRUD and snapshot endpoints. | +| `gateway.py` | `/gateways` | Thin gateway session-inspection API wrappers. | +| `gateways.py` | `/gateways` | Thin API wrappers for gateway CRUD and template synchronization. | +| `metrics.py` | `/metrics` | Dashboard metric aggregation endpoints. | +| `organizations.py` | `/organizations` | Organization management endpoints and membership/invite flows. | +| `souls_directory.py` | `/souls-directory` | API routes for searching and fetching souls-directory markdown entries. | +| `tasks.py` | `/boards/{board_id}/tasks` | Task API routes for listing, streaming, and mutating board tasks. | +| `users.py` | `/users` | User self-service API endpoints for profile retrieval and updates. | + +## Backend API layer notes (how modules are organized) + +Evidence: `backend/app/main.py`, `backend/app/api/*`, `backend/app/api/deps.py`. + +### Conventions + +- Each file under `backend/app/api/*` typically declares an `APIRouter` (`router = APIRouter(...)`) and defines endpoints with decorators like `@router.get(...)`, `@router.post(...)`, etc. +- Board-scoped modules embed `{board_id}` in the prefix (e.g. `/boards/{board_id}/tasks`). +- Streaming endpoints usually expose **SSE** endpoints at `.../stream` (see `sse-starlette` usage). + +### Where key behaviors live + +- **Router wiring / base prefix**: `backend/app/main.py` mounts these routers under `/api/v1/*`. +- **Auth / access control** is mostly expressed through dependencies (see `backend/app/api/deps.py`): + - `require_admin_auth` — require an authenticated *admin user*. + - `require_admin_or_agent` — allow either an admin user or an authenticated agent. + - `get_board_for_actor_read` / `get_board_for_actor_write` — enforce board access for the calling actor. + - `require_org_member` / `require_org_admin` — enforce org membership/admin for user callers. +- **Agent-only surface**: `backend/app/api/agent.py` uses `get_agent_auth_context` (X-Agent-Token) and contains board/task/memory endpoints specifically for automation. + +### Module-by-module map (prefix, key endpoints, and pointers) + +This is a “where to look” index, not a full OpenAPI dump. For exact parameters and response shapes, see: +- route module file (`backend/app/api/.py`) +- schemas (`backend/app/schemas/*`) +- models (`backend/app/models/*`) +- services (`backend/app/services/*`) + +| Module | Prefix (under `/api/v1`) | Key endpoints (examples) | Main deps / auth | Pointers (schemas/models/services) | +|---|---|---|---|---| +| `activity.py` | `/activity` | `GET /activity` (events); `GET /activity/task-comments` + `/stream` | `require_admin_or_agent`, `require_org_member` | `app/models/activity_events.py`, `app/schemas/activity_events.py` | +| `agent.py` | `/agent` | agent automation surface: boards/tasks/memory + gateway coordination | `get_agent_auth_context` (X-Agent-Token) | `backend/app/core/agent_auth.py`, `backend/app/services/openclaw/*` | +| `agents.py` | `/agents` | agent lifecycle + SSE stream + heartbeat | org-admin gated for user callers; some endpoints allow agent access via deps | `app/schemas/agents.py`, `app/services/openclaw/provisioning_db.py` | +| `approvals.py` | `/boards/{board_id}/approvals` | list/create/update approvals + `/stream` | `require_admin_or_agent` + board access deps | `app/models/approvals.py`, `app/schemas/approvals.py` | ## Where authorization is enforced diff --git a/docs/12-backend-core.md b/docs/12-backend-core.md new file mode 100644 index 00000000..980a20c4 --- /dev/null +++ b/docs/12-backend-core.md @@ -0,0 +1,137 @@ +# Backend core modules (auth/config/logging/errors) + +> Evidence basis: repo https://github.com/abhi1693/openclaw-mission-control @ commit `c3490630a4503d9c8142aaa3abf542e0d00b5035`. + +This page documents the backend “core” layer under `backend/app/core/*` plus the API dependency module `backend/app/api/deps.py`. + +It’s written for maintainers who need to answer: + +- “Where does configuration come from?” +- “How do user vs agent auth work?” +- “Where are authorization decisions enforced?” +- “What’s the error envelope / request-id behavior?” +- “How is logging structured and how do I get request-context in logs?” + +## Start here (reading order) + +1. `backend/app/core/config.py` — settings + env file loading +2. `backend/app/core/logging.py` — structured logging + request context +3. `backend/app/core/error_handling.py` — request-id middleware + exception envelope +4. `backend/app/core/auth.py` — Clerk/user auth resolution +5. `backend/app/core/agent_auth.py` — agent token auth resolution +6. `backend/app/api/deps.py` — how routes declare and enforce access + +## Configuration: loading & precedence + +**Primary file:** `backend/app/core/config.py` + +Key facts: +- Uses `pydantic-settings` (`BaseSettings`) to load typed settings from environment. +- Env files are loaded regardless of current working directory: + - `backend/.env` (via `DEFAULT_ENV_FILE`) + - then `.env` (repo root) as an additional source + - See `Settings.model_config.env_file=[DEFAULT_ENV_FILE, ".env"]`. +- Unknown env vars are ignored (`extra="ignore"`). + +Notable settings (security-sensitive in **bold**): +- `DATABASE_URL` / `database_url` +- `CORS_ORIGINS` / `cors_origins` +- `DB_AUTO_MIGRATE` / `db_auto_migrate` +- **`CLERK_SECRET_KEY` / `clerk_secret_key`** (must be non-empty; validator enforces it) +- `CLERK_API_URL`, `CLERK_VERIFY_IAT`, `CLERK_LEEWAY` +- logging knobs: `LOG_LEVEL`, `LOG_FORMAT`, `LOG_USE_UTC`, `REQUEST_LOG_SLOW_MS`, `REQUEST_LOG_INCLUDE_HEALTH` + +### Deployment implication + +- If a deployment accidentally starts the backend with an empty/placeholder `CLERK_SECRET_KEY`, the backend will fail settings validation at startup. + +## Auth model split + +The backend supports two top-level actor types: + +- **User** (human UI / admin) — resolved from the `Authorization: Bearer ` header via Clerk. +- **Agent** (automation) — resolved from `X-Agent-Token: ` (and optionally `Authorization: Bearer ` for agent callers). + +### User auth (Clerk) — `backend/app/core/auth.py` + +What it does: +- Uses the `clerk_backend_api` SDK to authenticate requests (`authenticate_request(...)`) using `CLERK_SECRET_KEY`. +- Resolves a `AuthContext` containing `actor_type="user"` and a `User` model instance. +- The module includes helpers to fetch user profile details from Clerk (`_fetch_clerk_profile`) and to delete a Clerk user (`delete_clerk_user`). + +Security-sensitive notes: +- Treat `CLERK_SECRET_KEY` as a credential; never log it. +- This code calls Clerk API endpoints over the network (timeouts and error handling matter). + +### Agent auth (token hash) — `backend/app/core/agent_auth.py` + +What it does: +- Requires a token header for protected agent endpoints: + - Primary header: `X-Agent-Token` + - Optional parsing: `Authorization: Bearer ...` (only in `get_agent_auth_context`, and only if `accept_authorization=True`) +- Validates token by comparing it against stored `agent_token_hash` values in the DB (`verify_agent_token`). +- “Touches” agent presence (`last_seen_at`, `status`) on authenticated requests. + - For safe methods (`GET/HEAD/OPTIONS`), it commits immediately so read-only polling still shows the agent as online. + +Security-sensitive notes: +- Token verification iterates over agents with a token hash. If this grows large, consider indexing/lookup strategy. +- Never echo full tokens in logs; current code logs only a prefix on invalid tokens. + +## Authorization enforcement: `backend/app/api/deps.py` + +This module is the primary “policy wiring” for most routes. + +Key concepts: + +- `require_admin_auth(...)` + - Requires an authenticated *admin user*. +- `require_admin_or_agent(...)` → returns `ActorContext` + - Allows either: + - admin user (user auth via Clerk), or + - authenticated agent (agent auth via X-Agent-Token). + +Board/task access patterns: +- `get_board_for_actor_read` / `get_board_for_actor_write` + - Enforces that the caller (user or agent) has the correct access to the board. + - Agent access is restricted if the agent is bound to a specific board (`agent.board_id`). +- `get_task_or_404` + - Loads a task and ensures it belongs to the requested board. + +Org access patterns (user callers): +- `require_org_member` and `require_org_admin` + - Resolve/require active org membership. + - Provide an `OrganizationContext` with `organization` + `member`. + +Maintainer tip: +- When debugging a “why is this 403/401?”, start by checking the route’s dependency stack (in the route module) and trace through the relevant dependency in `deps.py`. + +## Logging: structure + request context + +**Primary file:** `backend/app/core/logging.py` + +Highlights: +- Defines a custom TRACE level (`TRACE_LEVEL = 5`). +- Uses `contextvars` to carry `request_id`, `method`, and `path` across async tasks. +- `AppLogFilter` injects `app`, `version`, and request context into each log record. +- Supports JSON output (`JsonFormatter`) and key=value (`KeyValueFormatter`) formats. + +Where request context gets set: +- `backend/app/core/error_handling.py` middleware calls: + - `set_request_id(...)` + - `set_request_route_context(method, path)` + +## Error envelope + request-id + +**Primary file:** `backend/app/core/error_handling.py` + +Key behaviors: +- Installs a `RequestIdMiddleware` (ASGI) that: + - Accepts client-provided `X-Request-Id` or generates one. + - Adds `X-Request-Id` to the response. + - Emits structured “http.request.*” logs, including “slow request” warnings. +- Error responses include `request_id` when available: + - Validation errors (`422`) return `{detail: , request_id: ...}`. + - Other HTTP errors are wrapped similarly. + +Maintainer tip: +- When debugging incidents, ask for the `X-Request-Id` from the client and use it to locate backend logs quickly. diff --git a/docs/README.md b/docs/README.md index 8f75bbf1..243ba080 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,9 @@ This folder is the canonical documentation set for Mission Control. ## Table of contents (IA) +- [Style guide](00-style-guide.md) + + 1. [Overview](01-overview.md) 2. [Quickstart](02-quickstart.md) 3. [Development](03-development.md) @@ -17,6 +20,7 @@ This folder is the canonical documentation set for Mission Control. 5. [Architecture](05-architecture.md) 6. [Configuration](06-configuration.md) 7. [API reference](07-api-reference.md) + - [Frontend API + auth modules](frontend-api-auth.md) 8. [Agents & skills](08-agents-and-skills.md) 9. [Ops / runbooks](09-ops-runbooks.md) 10. [Troubleshooting](10-troubleshooting.md) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 183de6fd..ca78f901 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -2,7 +2,7 @@ Mission Control is the **web UI + HTTP API** for operating OpenClaw. It’s where you manage boards, tasks, agents, approvals, and (optionally) gateway connections. -> Auth note: **Clerk is required for now** (current product direction). The codebase includes gating so CI/local can run with placeholders, but real deployments should configure Clerk. +> Auth note: Mission Control supports two auth modes: `local` (shared bearer token) and `clerk`. At a high level: - The **frontend** is a Next.js app used by humans. @@ -29,10 +29,11 @@ flowchart LR - Routes/pages: `frontend/src/app/*` (Next.js App Router) - API utilities: `frontend/src/lib/*` and `frontend/src/api/*` -**Auth (Clerk, required)** -- Clerk is required for real deployments and currently required by backend config (see `backend/app/core/config.py`). -- Frontend uses Clerk when keys are configured; see `frontend/src/auth/clerkKey.ts` and `frontend/src/auth/clerk.tsx`. -- Backend authenticates requests using the Clerk SDK and `CLERK_SECRET_KEY`; see `backend/app/core/auth.py`. +**Auth (`local` or Clerk)** +- `local` mode authenticates a shared bearer token (`LOCAL_AUTH_TOKEN`) and resolves a local user context. +- `clerk` mode verifies Clerk JWTs using `CLERK_SECRET_KEY`. +- Frontend mode switch + wrappers: `frontend/src/auth/clerk.tsx`, `frontend/src/auth/localAuth.ts`, and `frontend/src/components/providers/AuthProvider.tsx`. +- Backend mode switch: `backend/app/core/config.py` and `backend/app/core/auth.py`. ### Backend (FastAPI) @@ -64,9 +65,13 @@ Mission Control can call into an OpenClaw Gateway over WebSockets. 2. Frontend calls backend endpoints under `/api/v1/*`. 3. Backend reads/writes Postgres. -### Auth (Clerk — required) -- **Frontend** uses Clerk when keys are configured (see `frontend/src/auth/*`). -- **Backend** authenticates requests using the Clerk SDK and `CLERK_SECRET_KEY` (see `backend/app/core/auth.py`). +### Auth (`local` or Clerk) +- **Frontend**: + - `local`: token entry screen + session storage token (`frontend/src/components/organisms/LocalAuthLogin.tsx`, `frontend/src/auth/localAuth.ts`). + - `clerk`: Clerk wrappers/hooks (`frontend/src/auth/clerk.tsx`). +- **Backend**: + - `local`: validates `Authorization: Bearer `. + - `clerk`: validates Clerk request state with SDK + `CLERK_SECRET_KEY`. ### Agent access (X-Agent-Token) Automation/agents can use the “agent” API surface: - Endpoints under `/api/v1/agent/*` (router: `backend/app/api/agent.py`). @@ -92,7 +97,7 @@ Backend: Frontend: - `frontend/src/app/` — Next.js routes - `frontend/src/components/` — UI components -- `frontend/src/auth/` — Clerk gating/wrappers +- `frontend/src/auth/` — auth mode helpers (`clerk` and `local`) - `frontend/src/lib/` — utilities + API base ## Where to start reading code @@ -106,7 +111,7 @@ Backend: Frontend: 1. `frontend/src/app/*` — main UI routes 2. `frontend/src/lib/api-base.ts` — backend calls -3. `frontend/src/auth/*` — Clerk integration (gated for CI/local) +3. `frontend/src/auth/*` — auth mode integration (`local` + Clerk) ## Related docs - Self-host (Docker Compose): see repo root README: [Quick start (self-host with Docker Compose)](../../README.md#quick-start-self-host-with-docker-compose) diff --git a/docs/deployment/README.md b/docs/deployment/README.md index 0d5aba68..3423ab26 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -15,7 +15,9 @@ When running Compose, you get: - Health check: `GET /healthz` - **Frontend UI** (Next.js) on `http://localhost:${FRONTEND_PORT:-3000}` -Auth (Clerk) is **required** right now. You must configure Clerk keys for the frontend and backend (`CLERK_SECRET_KEY`). +Auth is configurable per deployment: +- `AUTH_MODE=local` (self-host default; shared bearer token) +- `AUTH_MODE=clerk` (Clerk JWT auth; backend requires `CLERK_SECRET_KEY`) ## Requirements @@ -30,6 +32,9 @@ From repo root: ```bash cp .env.example .env +# REQUIRED for local mode: +# set LOCAL_AUTH_TOKEN in .env to a non-placeholder value with at least 50 characters. + docker compose -f compose.yml --env-file .env up -d --build ``` @@ -86,7 +91,7 @@ These persist across `docker compose down`. ### Root `.env` (Compose) - Copy the template: `cp .env.example .env` -- Edit values as needed (ports, Clerk URLs/keys, etc.) +- Edit values as needed (ports, auth mode, tokens, API URL, etc.) Compose is invoked with: @@ -110,49 +115,57 @@ Instead, it supports an optional user-managed env file: If present, Compose will load it. -## Clerk (auth) notes +## Authentication modes -Clerk is currently required. +Mission Control supports two deployment auth modes: -### Frontend (Clerk keys) +- `AUTH_MODE=local`: shared bearer token auth (self-host default) +- `AUTH_MODE=clerk`: Clerk JWT auth -Create `frontend/.env` (this file is **not** committed; `compose.yml` loads it if present): +### Local mode (self-host default) + +Set in `.env` (repo root): ```env -# Frontend → Backend -NEXT_PUBLIC_API_URL=http://localhost:8000 - -# Frontend → Clerk -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY -CLERK_SECRET_KEY=YOUR_SECRET_KEY - -# Optional (but recommended) redirects -NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/boards -NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/boards -NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards -NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/boards +AUTH_MODE=local +LOCAL_AUTH_TOKEN=replace-with-random-token-at-least-50-characters ``` -### Backend (auth) +Set frontend mode (optional override in `frontend/.env`): -The backend authenticates requests using the Clerk SDK and **`CLERK_SECRET_KEY`** (see `backend/app/core/auth.py`). +```env +NEXT_PUBLIC_AUTH_MODE=local +NEXT_PUBLIC_API_URL=http://localhost:8000 +``` -Create `backend/.env` (this file is **not** committed) with at least: +Users enter `LOCAL_AUTH_TOKEN` in the local login screen. + +### Clerk mode + +Set in `.env` (repo root): + +```env +AUTH_MODE=clerk +``` + +Create `backend/.env` with at least: ```env CLERK_SECRET_KEY=sk_test_your_real_key - -# Optional tuning CLERK_API_URL=https://api.clerk.com CLERK_VERIFY_IAT=true CLERK_LEEWAY=10.0 ``` -Then either: -1) update `compose.yml` to load `backend/.env` (recommended), or -2) pass the values via `services.backend.environment`. +Create `frontend/.env` with at least: -**Security:** treat `CLERK_SECRET_KEY` like a password. Do not commit it. +```env +NEXT_PUBLIC_AUTH_MODE=clerk +NEXT_PUBLIC_API_URL=http://localhost:8000 +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_real_key +``` + +**Security:** treat `LOCAL_AUTH_TOKEN` and `CLERK_SECRET_KEY` like passwords. Do not commit them. ## Troubleshooting diff --git a/docs/frontend-api-auth.md b/docs/frontend-api-auth.md new file mode 100644 index 00000000..c7e19899 --- /dev/null +++ b/docs/frontend-api-auth.md @@ -0,0 +1,109 @@ +# Frontend API client and auth integration + +This page documents the frontend integration points you’ll touch when changing how the UI talks to the backend or how auth is applied. + +## Related docs + +- [Architecture](05-architecture.md) +- [Configuration](06-configuration.md) +- [API reference](07-api-reference.md) + +## API base URL + +The frontend uses `NEXT_PUBLIC_API_URL` as the single source of truth for where to send API requests. + +- Code: `frontend/src/lib/api-base.ts` +- Behavior: + - reads `process.env.NEXT_PUBLIC_API_URL` + - normalizes by trimming trailing slashes + - throws early if missing/invalid + +In Docker Compose, `compose.yml` sets `NEXT_PUBLIC_API_URL` both: +- as a **build arg** (for `next build`), and +- as a **runtime env var**. + +## API client layout + +### Generated client + +- Location: `frontend/src/api/generated/*` +- Generator: **Orval** + - Config: `frontend/orval.config.ts` + - Script: `cd frontend && npm run api:gen` + - Convenience target: `make api-gen` + +By default, Orval reads the backend OpenAPI schema from: +- `ORVAL_INPUT` (if set), otherwise +- `http://127.0.0.1:8000/openapi.json` + +Output details (from `orval.config.ts`): +- Mode: `tags-split` +- Target index: `frontend/src/api/generated/index.ts` +- Schemas: `frontend/src/api/generated/model` +- Client: `react-query` +- All requests go through the custom mutator below. + +### Custom fetch / mutator + +All generated requests go through: + +- Code: `frontend/src/api/mutator.ts` +- What it does: + - resolves `NEXT_PUBLIC_API_URL` and builds the full request URL + - sets `Content-Type: application/json` when there’s a body + - injects `Authorization: Bearer ` when a Clerk session token is available + - converts non-2xx responses into a typed `ApiError` (status + parsed response) + +## Auth enablement and token injection + +### Clerk enablement (publishable key gating) + +Clerk is enabled in the frontend only when `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` looks valid. + +- Gating helper (dependency-free): `frontend/src/auth/clerkKey.ts` +- UI-safe wrappers/hooks: `frontend/src/auth/clerk.tsx` + - provides `SignedIn`, `SignedOut`, `SignInButton`, `SignOutButton`, `useUser`, and `useAuth` + - returns safe fallbacks when Clerk is disabled (to allow secretless builds/prerender) + +### Token injection + +When the UI makes an API request, the mutator attempts to read a token from the Clerk session: + +- Code: `frontend/src/api/mutator.ts` (`resolveClerkToken()`) +- If a token is available, the request includes: + - `Authorization: Bearer ` + +### Route protection (middleware) + +Request-time route protection is implemented via Next.js middleware: + +- Code: `frontend/src/proxy.ts` +- Behavior: + - when Clerk is enabled: uses `clerkMiddleware()` to enforce auth on non-public routes + - when Clerk is disabled: passes all requests through + +## Common workflows + +### Update the backend API and regenerate the client + +1. Run the backend so OpenAPI is available: + +```bash +# from repo root +cp backend/.env.example backend/.env +make backend-migrate +cd backend && uv run uvicorn app.main:app --reload --port 8000 +``` + +2. Regenerate the client: + +```bash +# from repo root +make api-gen + +# or from frontend/ +ORVAL_INPUT=http://127.0.0.1:8000/openapi.json npm run api:gen +``` + +3. Review diffs under `frontend/src/api/generated/*`. + diff --git a/docs/production/README.md b/docs/production/README.md index 2933c888..98f1db4b 100644 --- a/docs/production/README.md +++ b/docs/production/README.md @@ -59,8 +59,10 @@ Recommended approach: Secrets guidelines: -- **Clerk auth is required for now**: you must configure Clerk keys/JWKS for the app to work. -- Never commit Clerk secret key. +- Choose auth mode explicitly: + - `AUTH_MODE=local`: set `LOCAL_AUTH_TOKEN` to a random value with at least 50 characters + - `AUTH_MODE=clerk`: configure Clerk keys +- Never commit `LOCAL_AUTH_TOKEN` or Clerk secret key. - Prefer passing secrets as environment variables from the host (or use Docker secrets if you later migrate to Swarm/K8s). - Rotate secrets if they ever hit logs. @@ -75,7 +77,7 @@ sudo git clone https://github.com/abhi1693/openclaw-mission-control.git mission- cd mission-control cp .env.example .env -# edit .env with real values (domains, Clerk keys, etc.) +# edit .env with real values (domains, auth mode + secrets, etc.) docker compose -f compose.yml --env-file .env up -d --build ``` diff --git a/frontend/.env.example b/frontend/.env.example index f8e1716d..b1db4249 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -2,10 +2,12 @@ # Must be reachable from the browser (host). NEXT_PUBLIC_API_URL=http://localhost:8000 -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY -CLERK_SECRET_KEY=YOUR_SECRET_KEY -NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/boards -NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/boards +# Auth mode: clerk or local. +# - clerk: Clerk sign-in flow +# - local: shared bearer token entered in UI +NEXT_PUBLIC_AUTH_MODE=local + +# Clerk auth (used when NEXT_PUBLIC_AUTH_MODE=clerk) +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards -NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/boards NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL=/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile index fb396bf9..a834856f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -15,6 +15,8 @@ COPY . ./ # Allows configuring the API URL at build time. ARG NEXT_PUBLIC_API_URL=http://localhost:8000 ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +ARG NEXT_PUBLIC_AUTH_MODE +ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE} RUN npm run build @@ -22,10 +24,12 @@ FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production +ARG NEXT_PUBLIC_AUTH_MODE # If provided at runtime, Next will expose NEXT_PUBLIC_* to the browser as well # (but note some values may be baked at build time). ENV NEXT_PUBLIC_API_URL=http://localhost:8000 +ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE} COPY --from=builder /app/.next ./.next # `public/` is optional in Next.js apps; repo may not have it. diff --git a/frontend/README.md b/frontend/README.md index 8b858b4a..33052326 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -4,7 +4,9 @@ This package is the **Next.js** web UI for OpenClaw Mission Control. - Talks to the Mission Control **backend** over HTTP (typically `http://localhost:8000`). - Uses **React Query** for data fetching. -- Can optionally enable **Clerk** authentication (disabled by default unless you provide a _real_ Clerk publishable key). +- Supports two auth modes: + - **local** shared bearer token mode (self-host default) + - **clerk** mode ## Prerequisites @@ -53,27 +55,23 @@ Example: NEXT_PUBLIC_API_URL=http://localhost:8000 ``` -### Optional: Clerk authentication +### Authentication mode -Clerk is **optional**. +Set `NEXT_PUBLIC_AUTH_MODE` to one of: -The app only enables Clerk when `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` looks like a real key. -Implementation detail: we gate on a conservative regex (`pk_test_...` / `pk_live_...`) in `src/auth/clerkKey.ts`. +- `local` (default for self-host) +- `clerk` -#### Env vars +For `local` mode: + +- users enter the token in the local login screen +- requests use that token as `Authorization: Bearer ...` + +For `clerk` mode, configure: - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` - - If **unset/blank/placeholder**, Clerk is treated as **disabled**. -- `CLERK_SECRET_KEY` - - Required only if you enable Clerk features that need server-side verification. -- Redirect URLs (optional; used by Clerk UI flows): - - `NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL` - - `NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL` - - `NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL` - - `NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL` - -**Important:** `frontend/.env.example` contains placeholder values like `YOUR_PUBLISHABLE_KEY`. -Those placeholders are _not_ valid keys and are intentionally treated as “Clerk disabled”. +- optional `NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL` +- optional `NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL` ## How the frontend talks to the backend @@ -107,7 +105,7 @@ All Orval-generated requests go through the custom mutator (`src/api/mutator.ts` It will: - set `Content-Type: application/json` when there is a body and you didn’t specify a content type -- add `Authorization: Bearer ` automatically **if** Clerk is enabled and there is an active Clerk session in the browser +- add `Authorization: Bearer ` automatically from local mode token or Clerk session - parse errors into an `ApiError` with status + parsed response body ## Common commands @@ -149,11 +147,11 @@ cp .env.example .env.local - Confirm `NEXT_PUBLIC_API_URL` points to the correct host/port. - If accessing from another device (LAN), use a reachable backend URL (not `localhost`). -### Clerk redirects / auth UI shows unexpectedly +### Wrong auth mode UI -Clerk should be **off** unless you set a real `pk_test_...` or `pk_live_...` publishable key. - -- Remove/blank `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` in your `.env.local` to force Clerk off. +- Ensure `NEXT_PUBLIC_AUTH_MODE` matches backend `AUTH_MODE`. +- For local mode, set `NEXT_PUBLIC_AUTH_MODE=local`. +- For Clerk mode, set `NEXT_PUBLIC_AUTH_MODE=clerk` and a real Clerk publishable key. ### Dev server blocked by origin restrictions diff --git a/frontend/cypress/e2e/activity_feed.cy.ts b/frontend/cypress/e2e/activity_feed.cy.ts index a80af55e..ff266bb1 100644 --- a/frontend/cypress/e2e/activity_feed.cy.ts +++ b/frontend/cypress/e2e/activity_feed.cy.ts @@ -4,6 +4,18 @@ describe("/activity feed", () => { const apiBase = "**/api/v1"; const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout"); + + beforeEach(() => { + // Clerk's Cypress helpers perform async work inside `cy.then()`. + // CI can be slow enough that the default 4s command timeout flakes. + Cypress.config("defaultCommandTimeout", 20_000); + }); + + afterEach(() => { + Cypress.config("defaultCommandTimeout", originalDefaultCommandTimeout); + }); + function stubStreamEmpty() { cy.intercept( "GET", diff --git a/frontend/src/api/mutator.ts b/frontend/src/api/mutator.ts index 755b1041..610125f3 100644 --- a/frontend/src/api/mutator.ts +++ b/frontend/src/api/mutator.ts @@ -1,3 +1,5 @@ +import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth"; + type ClerkSession = { getToken: () => Promise; }; @@ -48,6 +50,12 @@ export const customFetch = async ( if (hasBody && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } + if (isLocalAuthMode() && !headers.has("Authorization")) { + const token = getLocalAuthToken(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + } if (!headers.has("Authorization")) { const token = await resolveClerkToken(); if (token) { diff --git a/frontend/src/auth/clerk.tsx b/frontend/src/auth/clerk.tsx index c134a699..a6508d42 100644 --- a/frontend/src/auth/clerk.tsx +++ b/frontend/src/auth/clerk.tsx @@ -16,21 +16,33 @@ import { } from "@clerk/nextjs"; import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; +import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth"; + +function hasLocalAuthToken(): boolean { + return Boolean(getLocalAuthToken()); +} export function isClerkEnabled(): boolean { // IMPORTANT: keep this in sync with AuthProvider; otherwise components like // may render without a and crash during prerender. + if (isLocalAuthMode()) return false; return isLikelyValidClerkPublishableKey( process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, ); } export function SignedIn(props: { children: ReactNode }) { + if (isLocalAuthMode()) { + return hasLocalAuthToken() ? <>{props.children} : null; + } if (!isClerkEnabled()) return null; return {props.children}; } export function SignedOut(props: { children: ReactNode }) { + if (isLocalAuthMode()) { + return hasLocalAuthToken() ? null : <>{props.children}; + } if (!isClerkEnabled()) return <>{props.children}; return {props.children}; } @@ -49,6 +61,13 @@ export function SignOutButton( } export function useUser() { + if (isLocalAuthMode()) { + return { + isLoaded: true, + isSignedIn: hasLocalAuthToken(), + user: null, + } as const; + } if (!isClerkEnabled()) { return { isLoaded: true, isSignedIn: false, user: null } as const; } @@ -56,6 +75,16 @@ export function useUser() { } export function useAuth() { + if (isLocalAuthMode()) { + const token = getLocalAuthToken(); + return { + isLoaded: true, + isSignedIn: Boolean(token), + userId: token ? "local-user" : null, + sessionId: token ? "local-session" : null, + getToken: async () => token, + } as const; + } if (!isClerkEnabled()) { return { isLoaded: true, diff --git a/frontend/src/auth/localAuth.ts b/frontend/src/auth/localAuth.ts new file mode 100644 index 00000000..cfe59c97 --- /dev/null +++ b/frontend/src/auth/localAuth.ts @@ -0,0 +1,45 @@ +"use client"; + +import { AuthMode } from "@/auth/mode"; + +let localToken: string | null = null; +const STORAGE_KEY = "mc_local_auth_token"; + +export function isLocalAuthMode(): boolean { + return process.env.NEXT_PUBLIC_AUTH_MODE === AuthMode.Local; +} + +export function setLocalAuthToken(token: string): void { + localToken = token; + if (typeof window === "undefined") return; + try { + window.sessionStorage.setItem(STORAGE_KEY, token); + } catch { + // Ignore storage failures (private mode / policy). + } +} + +export function getLocalAuthToken(): string | null { + if (localToken) return localToken; + if (typeof window === "undefined") return null; + try { + const stored = window.sessionStorage.getItem(STORAGE_KEY); + if (stored) { + localToken = stored; + return stored; + } + } catch { + // Ignore storage failures (private mode / policy). + } + return null; +} + +export function clearLocalAuthToken(): void { + localToken = null; + if (typeof window === "undefined") return; + try { + window.sessionStorage.removeItem(STORAGE_KEY); + } catch { + // Ignore storage failures (private mode / policy). + } +} diff --git a/frontend/src/auth/mode.ts b/frontend/src/auth/mode.ts new file mode 100644 index 00000000..6bf1d992 --- /dev/null +++ b/frontend/src/auth/mode.ts @@ -0,0 +1,4 @@ +export enum AuthMode { + Clerk = "clerk", + Local = "local", +} diff --git a/frontend/src/components/organisms/LocalAuthLogin.test.tsx b/frontend/src/components/organisms/LocalAuthLogin.test.tsx new file mode 100644 index 00000000..f02f7597 --- /dev/null +++ b/frontend/src/components/organisms/LocalAuthLogin.test.tsx @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { LocalAuthLogin } from "./LocalAuthLogin"; + +const setLocalAuthTokenMock = vi.hoisted(() => vi.fn()); +const fetchMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/auth/localAuth", async () => { + const actual = + await vi.importActual( + "@/auth/localAuth", + ); + return { + ...actual, + setLocalAuthToken: setLocalAuthTokenMock, + }; +}); + +describe("LocalAuthLogin", () => { + beforeEach(() => { + fetchMock.mockReset(); + setLocalAuthTokenMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("NEXT_PUBLIC_API_URL", "http://localhost:8000/"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("requires a non-empty token", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Continue" })); + + expect(screen.getByText("Bearer token is required.")).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(setLocalAuthTokenMock).not.toHaveBeenCalled(); + }); + + it("requires token length of at least 50 characters", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText("Paste token"), "x".repeat(49)); + await user.click(screen.getByRole("button", { name: "Continue" })); + + expect( + screen.getByText("Bearer token must be at least 50 characters."), + ).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(setLocalAuthTokenMock).not.toHaveBeenCalled(); + }); + + it("rejects invalid token values", async () => { + const onAuthenticatedMock = vi.fn(); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 })); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText("Paste token"), "x".repeat(50)); + await user.click(screen.getByRole("button", { name: "Continue" })); + + await waitFor(() => + expect(screen.getByText("Token is invalid.")).toBeInTheDocument(), + ); + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:8000/api/v1/users/me", + expect.objectContaining({ + method: "GET", + headers: { Authorization: `Bearer ${"x".repeat(50)}` }, + }), + ); + expect(setLocalAuthTokenMock).not.toHaveBeenCalled(); + expect(onAuthenticatedMock).not.toHaveBeenCalled(); + }); + + it("saves token only after successful backend validation", async () => { + const onAuthenticatedMock = vi.fn(); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 })); + const user = userEvent.setup(); + render(); + + const token = ` ${"g".repeat(50)} `; + await user.type(screen.getByPlaceholderText("Paste token"), token); + await user.click(screen.getByRole("button", { name: "Continue" })); + + await waitFor(() => + expect(setLocalAuthTokenMock).toHaveBeenCalledWith("g".repeat(50)), + ); + expect(onAuthenticatedMock).toHaveBeenCalledTimes(1); + }); + + it("shows a clear error when backend is unreachable", async () => { + const onAuthenticatedMock = vi.fn(); + fetchMock.mockRejectedValueOnce(new TypeError("network error")); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText("Paste token"), "t".repeat(50)); + await user.click(screen.getByRole("button", { name: "Continue" })); + + await waitFor(() => + expect( + screen.getByText("Unable to reach backend to validate token."), + ).toBeInTheDocument(), + ); + expect(setLocalAuthTokenMock).not.toHaveBeenCalled(); + expect(onAuthenticatedMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/organisms/LocalAuthLogin.tsx b/frontend/src/components/organisms/LocalAuthLogin.tsx new file mode 100644 index 00000000..fd7ab8c3 --- /dev/null +++ b/frontend/src/components/organisms/LocalAuthLogin.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useState } from "react"; +import { Lock } from "lucide-react"; + +import { setLocalAuthToken } from "@/auth/localAuth"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; + +const LOCAL_AUTH_TOKEN_MIN_LENGTH = 50; + +async function validateLocalToken(token: string): Promise { + const rawBaseUrl = process.env.NEXT_PUBLIC_API_URL; + if (!rawBaseUrl) { + return "NEXT_PUBLIC_API_URL is not set."; + } + + const baseUrl = rawBaseUrl.replace(/\/+$/, ""); + + let response: Response; + try { + response = await fetch(`${baseUrl}/api/v1/users/me`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + } catch { + return "Unable to reach backend to validate token."; + } + + if (response.ok) { + return null; + } + if (response.status === 401 || response.status === 403) { + return "Token is invalid."; + } + return `Unable to validate token (HTTP ${response.status}).`; +} + +type LocalAuthLoginProps = { + onAuthenticated?: () => void; +}; + +const defaultOnAuthenticated = () => window.location.reload(); + +export function LocalAuthLogin({ onAuthenticated }: LocalAuthLoginProps) { + const [token, setToken] = useState(""); + const [error, setError] = useState(null); + const [isValidating, setIsValidating] = useState(false); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const cleaned = token.trim(); + if (!cleaned) { + setError("Bearer token is required."); + return; + } + if (cleaned.length < LOCAL_AUTH_TOKEN_MIN_LENGTH) { + setError( + `Bearer token must be at least ${LOCAL_AUTH_TOKEN_MIN_LENGTH} characters.`, + ); + return; + } + + setIsValidating(true); + const validationError = await validateLocalToken(cleaned); + setIsValidating(false); + if (validationError) { + setError(validationError); + return; + } + + setLocalAuthToken(cleaned); + setError(null); + (onAuthenticated ?? defaultOnAuthenticated)(); + }; + + return ( +
+
+
+
+
+ + + +
+ + Self-host mode + +
+ +
+
+
+

+ Local Authentication +

+

+ Enter your access token to unlock Mission Control. +

+
+
+ +
+
+ + setToken(event.target.value)} + placeholder="Paste token" + autoFocus + disabled={isValidating} + className="font-mono" + /> +
+ {error ? ( +

+ {error} +

+ ) : ( +

+ Token must be at least {LOCAL_AUTH_TOKEN_MIN_LENGTH} characters. +

+ )} + +
+
+
+
+ ); +} diff --git a/frontend/src/components/organisms/UserMenu.tsx b/frontend/src/components/organisms/UserMenu.tsx index c074e2fe..97f9743a 100644 --- a/frontend/src/components/organisms/UserMenu.tsx +++ b/frontend/src/components/organisms/UserMenu.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import Link from "next/link"; import { useState } from "react"; import { SignOutButton, useUser } from "@/auth/clerk"; +import { clearLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth"; import { Activity, Bot, @@ -36,13 +37,17 @@ export function UserMenu({ }: UserMenuProps) { const [open, setOpen] = useState(false); const { user } = useUser(); - if (!user) return null; + const localMode = isLocalAuthMode(); + if (!user && !localMode) return null; - const avatarUrl = user.imageUrl ?? null; - const avatarLabelSource = displayNameFromDb ?? user.id ?? "U"; + const avatarUrl = localMode ? null : (user?.imageUrl ?? null); + const avatarLabelSource = + displayNameFromDb ?? (localMode ? "Local User" : user?.id) ?? "U"; const avatarLabel = avatarLabelSource.slice(0, 1).toUpperCase(); - const displayName = displayNameFromDb ?? "Account"; - const displayEmail = displayEmailFromDb ?? ""; + const displayName = + displayNameFromDb ?? (localMode ? "Local User" : "Account"); + const displayEmail = + displayEmailFromDb ?? (localMode ? "local@localhost" : ""); return ( @@ -166,16 +171,31 @@ export function UserMenu({
- + {localMode ? ( - + ) : ( + + + + )}
diff --git a/frontend/src/components/providers/AuthProvider.tsx b/frontend/src/components/providers/AuthProvider.tsx index bbe44ffb..eccf107e 100644 --- a/frontend/src/components/providers/AuthProvider.tsx +++ b/frontend/src/components/providers/AuthProvider.tsx @@ -1,11 +1,32 @@ "use client"; import { ClerkProvider } from "@clerk/nextjs"; -import type { ReactNode } from "react"; +import { useEffect, type ReactNode } from "react"; import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; +import { + clearLocalAuthToken, + getLocalAuthToken, + isLocalAuthMode, +} from "@/auth/localAuth"; +import { LocalAuthLogin } from "@/components/organisms/LocalAuthLogin"; export function AuthProvider({ children }: { children: ReactNode }) { + const localMode = isLocalAuthMode(); + + useEffect(() => { + if (!localMode) { + clearLocalAuthToken(); + } + }, [localMode]); + + if (localMode) { + if (!getLocalAuthToken()) { + return ; + } + return <>{children}; + } + const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; const afterSignOutUrl = process.env.NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL ?? "/"; diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts index 55604bb3..0052d4ba 100644 --- a/frontend/src/proxy.ts +++ b/frontend/src/proxy.ts @@ -2,8 +2,10 @@ import { NextResponse } from "next/server"; import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; +import { AuthMode } from "@/auth/mode"; const isClerkEnabled = () => + process.env.NEXT_PUBLIC_AUTH_MODE !== AuthMode.Local && isLikelyValidClerkPublishableKey( process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, );