feat: add boards and tasks management endpoints

This commit is contained in:
Abhimanyu Saharan
2026-02-04 02:28:51 +05:30
parent 23faa0865b
commit 1abc8f68f3
170 changed files with 6860 additions and 10706 deletions

View File

97
backend/app/core/auth.py Normal file
View File

@@ -0,0 +1,97 @@
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from typing import Literal
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi_clerk_auth import ClerkConfig, ClerkHTTPBearer
from fastapi_clerk_auth import HTTPAuthorizationCredentials as ClerkCredentials
from pydantic import BaseModel, ValidationError
from sqlmodel import Session, select
from app.core.config import settings
from app.db.session import get_session
from app.models.users import User
security = HTTPBearer(auto_error=False)
class ClerkTokenPayload(BaseModel):
sub: str
@lru_cache
def _build_clerk_http_bearer(auto_error: bool) -> ClerkHTTPBearer:
if not settings.clerk_jwks_url:
raise RuntimeError("CLERK_JWKS_URL is not set.")
clerk_config = ClerkConfig(
jwks_url=settings.clerk_jwks_url,
verify_iat=settings.clerk_verify_iat,
leeway=settings.clerk_leeway,
)
return ClerkHTTPBearer(config=clerk_config, auto_error=auto_error, add_state=True)
@dataclass
class AuthContext:
actor_type: Literal["user"]
user: User | None = None
def _resolve_clerk_auth(
request: Request, fallback: ClerkCredentials | None
) -> ClerkCredentials | None:
auth_data = getattr(request.state, "clerk_auth", None)
return auth_data or fallback
def _parse_subject(auth_data: ClerkCredentials | None) -> str | None:
if not auth_data or not auth_data.decoded:
return None
payload = ClerkTokenPayload.model_validate(auth_data.decoded)
return payload.sub
async def get_auth_context(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
session: Session = Depends(get_session),
) -> AuthContext:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
try:
guard = _build_clerk_http_bearer(auto_error=False)
clerk_credentials = await guard(request)
except (RuntimeError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from exc
except HTTPException as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc
auth_data = _resolve_clerk_auth(request, clerk_credentials)
try:
clerk_user_id = _parse_subject(auth_data)
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc
if not clerk_user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
user = session.exec(select(User).where(User.clerk_user_id == clerk_user_id)).first()
if user is None:
claims = auth_data.decoded if auth_data and auth_data.decoded else {}
user = User(
clerk_user_id=clerk_user_id,
email=claims.get("email"),
name=claims.get("name"),
)
session.add(user)
session.commit()
session.refresh(user)
return AuthContext(
actor_type="user",
user=user,
)

View File

@@ -4,10 +4,29 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
environment: str = "dev"
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/openclaw_agency"
redis_url: str = "redis://localhost:6379/0"
# Clerk auth (auth only; roles stored in DB)
clerk_jwks_url: str = ""
clerk_verify_iat: bool = True
clerk_leeway: float = 10.0
# OpenClaw Gateway
openclaw_gateway_url: str = ""
openclaw_gateway_token: str = ""
database_url: str
cors_origins: str = ""
# Database lifecycle
db_auto_migrate: bool = False
settings = Settings() # type: ignore
settings = Settings()

View File

@@ -2,59 +2,13 @@ from __future__ import annotations
import logging
import os
import sys
from typing import Any
def _level() -> str:
return (os.environ.get("LOG_LEVEL") or os.environ.get("UVICORN_LOG_LEVEL") or "INFO").upper()
def configure_logging() -> None:
"""Configure app logging to stream to stdout.
Uvicorn already logs requests, but we want our app/integrations logs to be visible
in the same console stream.
"""
level = getattr(logging, _level(), logging.INFO)
root = logging.getLogger()
root.setLevel(level)
# Avoid duplicate handlers (e.g., when autoreload imports twice)
if not any(isinstance(h, logging.StreamHandler) for h in root.handlers):
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(level)
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%SZ",
)
handler.setFormatter(formatter)
root.addHandler(handler)
# Make common noisy loggers respect our level
for name in [
"uvicorn",
"uvicorn.error",
"uvicorn.access",
"httpx",
"requests",
]:
logging.getLogger(name).setLevel(level)
# Hide SQLAlchemy engine chatter unless explicitly debugging.
# (You can still enable it by setting LOG_LEVEL=DEBUG and adjusting this.)
logging.getLogger("sqlalchemy").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.dialects").setLevel(logging.WARNING)
def log_kv(logger: logging.Logger, msg: str, **kv: Any) -> None:
# Lightweight key-value logging without requiring JSON logging.
if kv:
suffix = " ".join(f"{k}={v!r}" for k, v in kv.items())
logger.info(f"{msg} | {suffix}")
else:
logger.info(msg)
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
level = logging._nameToLevel.get(level_name, logging.INFO)
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
force=True,
)

View File

@@ -1,35 +0,0 @@
from __future__ import annotations
def public_api_base_url() -> str:
"""Return a LAN-reachable base URL for the Mission Control API.
Priority:
1) MISSION_CONTROL_BASE_URL env var (recommended)
2) First non-loopback IPv4 from `hostname -I`
Never returns localhost because agents may run on another machine.
"""
import os
import re
import subprocess
explicit = os.environ.get("MISSION_CONTROL_BASE_URL")
if explicit:
return explicit.rstrip("/")
try:
out = subprocess.check_output(["bash", "-lc", "hostname -I"], text=True).strip()
ips = re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", out)
for ip in ips:
if ip.startswith("127."):
continue
if ip.startswith("172.17."):
continue
if ip.startswith(("192.168.", "10.", "172.")):
return f"http://{ip}:8000"
except Exception:
pass
return "http://<dev-machine-ip>:8000"