Add modular app structure for L1 server refactoring

Phase 2 of the full modernization:
- App factory pattern with create_app()
- Settings via dataclass with env vars
- Dependency injection container
- Router stubs for auth, storage, api, recipes, cache, runs
- Service layer stubs for run, recipe, cache
- Repository layer placeholder

Routes are stubs that import from legacy server.py during migration.
Next: Migrate each router fully with templates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
giles
2026-01-11 07:08:08 +00:00
parent 3e24156c99
commit adc876dbd6
16 changed files with 1057 additions and 0 deletions

23
app/routers/__init__.py Normal file
View File

@@ -0,0 +1,23 @@
"""
L1 Server Routers.
Each router handles a specific domain of functionality.
"""
from . import auth
from . import storage
from . import api
from . import recipes
from . import cache
from . import runs
from . import home
__all__ = [
"auth",
"storage",
"api",
"recipes",
"cache",
"runs",
"home",
]

40
app/routers/api.py Normal file
View File

@@ -0,0 +1,40 @@
"""
3-phase API routes for L1 server.
Provides the plan/execute/run-recipe endpoints for programmatic access.
"""
from fastapi import APIRouter, Request, Depends, HTTPException
from artdag_common.models.requests import PlanRequest, ExecutePlanRequest, RecipeRunRequest
from ..dependencies import require_auth
router = APIRouter()
# TODO: Migrate routes from server.py lines 6036-6241
# - POST /plan - Generate execution plan
# - POST /execute - Execute a plan
# - POST /run-recipe - Run complete recipe
@router.post("/plan")
async def generate_plan(request: PlanRequest):
"""Generate an execution plan from recipe without executing."""
# TODO: Implement
raise HTTPException(501, "Not yet migrated")
@router.post("/execute")
async def execute_plan(request: ExecutePlanRequest):
"""Execute a previously generated plan."""
# TODO: Implement
raise HTTPException(501, "Not yet migrated")
@router.post("/run-recipe")
async def run_recipe(request: RecipeRunRequest):
"""Run a complete recipe through all 3 phases."""
# TODO: Implement
raise HTTPException(501, "Not yet migrated")

119
app/routers/auth.py Normal file
View File

@@ -0,0 +1,119 @@
"""
Authentication routes for L1 server.
L1 doesn't handle login directly - users log in at their L2 server.
Token is passed via URL from L2 redirect, then L1 sets its own cookie.
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
# Import auth utilities from existing server module
# TODO: Move these to a service
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
router = APIRouter()
security = HTTPBearer(auto_error=False)
class RevokeUserRequest(BaseModel):
"""Request to revoke all tokens for a user."""
username: str
l2_server: str
@router.get("")
async def auth_callback(auth_token: str = None):
"""
Receive auth token from L2 redirect and set local cookie.
This enables cross-subdomain auth on iOS Safari which blocks shared cookies.
L2 redirects here with ?auth_token=... after user logs in.
"""
# Import here to avoid circular imports
from server import get_verified_user_context, register_user_token
if not auth_token:
return RedirectResponse(url="/", status_code=302)
# Verify the token is valid
ctx = await get_verified_user_context(auth_token)
if not ctx:
return RedirectResponse(url="/", status_code=302)
# Register token for this user (for revocation by username later)
register_user_token(ctx.username, auth_token)
# Set local first-party cookie and redirect to runs
response = RedirectResponse(url="/runs", status_code=302)
response.set_cookie(
key="auth_token",
value=auth_token,
httponly=True,
max_age=60 * 60 * 24 * 30, # 30 days
samesite="lax",
secure=True
)
return response
@router.get("/logout")
async def logout():
"""
Logout - clear local cookie and redirect to home.
Note: This only logs out of L1. User should also logout from L2.
"""
response = RedirectResponse(url="/", status_code=302)
response.delete_cookie("auth_token")
return response
@router.post("/revoke")
async def revoke_token(
credentials: HTTPAuthorizationCredentials = Depends(security),
):
"""
Revoke a token. Called by L2 when user logs out.
The token to revoke is passed in the Authorization header.
"""
from server import get_user_context_from_token, revoke_token as do_revoke_token
if not credentials:
raise HTTPException(401, "No token provided")
token = credentials.credentials
# Verify token is valid before revoking (ensures caller has the token)
ctx = get_user_context_from_token(token)
if not ctx:
raise HTTPException(401, "Invalid token")
# Revoke the token
newly_revoked = do_revoke_token(token)
return {"revoked": True, "newly_revoked": newly_revoked}
@router.post("/revoke-user")
async def revoke_user_tokens(request: RevokeUserRequest):
"""
Revoke all tokens for a user. Called by L2 when user logs out.
This handles the case where L2 issued scoped tokens that differ from L2's own token.
"""
from server import revoke_all_user_tokens
# Revoke all tokens registered for this user
count = revoke_all_user_tokens(request.username)
return {
"revoked": True,
"tokens_revoked": count,
"username": request.username
}

55
app/routers/cache.py Normal file
View File

@@ -0,0 +1,55 @@
"""
Cache and media routes for L1 server.
Handles content retrieval, metadata, media preview, and publishing.
"""
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse, FileResponse
from artdag_common.middleware import UserContext, wants_html
from ..dependencies import require_auth, get_templates, get_current_user
router = APIRouter()
# TODO: Migrate routes from server.py lines 2767-4200
# - GET /cache/{content_hash} - Get content details
# - GET /cache/{content_hash}/raw - Download raw file
# - GET /cache/{content_hash}/mp4 - Video conversion
# - GET /cache/{content_hash}/meta - Get metadata
# - PATCH /cache/{content_hash}/meta - Update metadata
# - POST /cache/{content_hash}/publish - Publish to L2
# - PATCH /cache/{content_hash}/republish - Republish
# - DELETE /cache/{content_hash} - Delete content
# - POST /cache/import - Import from IPFS
# - POST /cache/upload - Upload content
# - GET /media - Media list
@router.get("/cache/{content_hash}")
async def get_cache_item(
content_hash: str,
request: Request,
):
"""Get cached content details or serve the file."""
# TODO: Implement with content negotiation
raise HTTPException(501, "Not yet migrated")
@router.get("/cache/{content_hash}/raw")
async def download_raw(content_hash: str):
"""Download the raw cached file."""
# TODO: Implement
raise HTTPException(501, "Not yet migrated")
@router.get("/media")
async def list_media(
request: Request,
user: UserContext = Depends(require_auth),
):
"""List all media in cache."""
# TODO: Implement
raise HTTPException(501, "Not yet migrated")

49
app/routers/home.py Normal file
View File

@@ -0,0 +1,49 @@
"""
Home and root routes for L1 server.
"""
from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from artdag_common import render
from artdag_common.middleware import wants_html
from ..dependencies import get_templates, get_current_user
router = APIRouter()
@router.get("/")
async def home(request: Request):
"""
Home page - redirect to runs if authenticated, show landing otherwise.
"""
user = await get_current_user(request)
if user:
return RedirectResponse(url="/runs", status_code=302)
# For now, redirect to login at L2
# TODO: Show a landing page with login link
return RedirectResponse(url="/runs", status_code=302)
@router.get("/login")
async def login_redirect(request: Request):
"""
Redirect to L2 for login.
"""
from ..config import settings
if settings.l2_server:
# Redirect to L2 login with return URL
return_url = str(request.url_for("auth_callback"))
login_url = f"{settings.l2_server}/login?return_to={return_url}"
return RedirectResponse(url=login_url, status_code=302)
# No L2 configured - show error
return HTMLResponse(
"<html><body><h1>Login not configured</h1>"
"<p>No L2 server configured for authentication.</p></body></html>",
status_code=503
)

47
app/routers/recipes.py Normal file
View File

@@ -0,0 +1,47 @@
"""
Recipe management routes for L1 server.
Handles recipe upload, listing, viewing, and execution.
"""
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse
from artdag_common.middleware import UserContext, wants_html
from ..dependencies import require_auth, get_templates
router = APIRouter()
# TODO: Migrate routes from server.py lines 1990-2767
# - POST /recipes/upload - Upload recipe YAML
# - GET /recipes - List recipes
# - GET /recipes/{recipe_id} - Get recipe details
# - DELETE /recipes/{recipe_id} - Delete recipe
# - POST /recipes/{recipe_id}/run - Run recipe
# - GET /recipe/{recipe_id} - Recipe detail page
# - GET /recipe/{recipe_id}/dag - Recipe DAG visualization
# - POST /ui/recipes/{recipe_id}/run - Run from UI
# - GET /ui/recipes-list - Recipes list UI
@router.get("/recipes")
async def list_recipes(
request: Request,
user: UserContext = Depends(require_auth),
):
"""List available recipes."""
# TODO: Implement
raise HTTPException(501, "Not yet migrated")
@router.get("/recipe/{recipe_id}")
async def recipe_detail(
recipe_id: str,
request: Request,
user: UserContext = Depends(require_auth),
):
"""Recipe detail page with DAG visualization."""
# TODO: Implement
raise HTTPException(501, "Not yet migrated")

57
app/routers/runs.py Normal file
View File

@@ -0,0 +1,57 @@
"""
Run management routes for L1 server.
Handles run creation, status, listing, and detail views.
"""
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse
from artdag_common.middleware import UserContext, wants_html
from ..dependencies import require_auth, get_templates, get_current_user
router = APIRouter()
# TODO: Migrate routes from server.py lines 675-1789
# - POST /runs - Create run
# - GET /runs/{run_id} - Get run status
# - DELETE /runs/{run_id} - Delete run
# - GET /runs - List runs
# - GET /run/{run_id} - Run detail page
# - GET /run/{run_id}/plan - Plan visualization
# - GET /run/{run_id}/analysis - Analysis results
# - GET /run/{run_id}/artifacts - Artifacts list
@router.get("/runs")
async def list_runs(
request: Request,
user: UserContext = Depends(require_auth),
):
"""List all runs for the current user."""
# TODO: Implement
raise HTTPException(501, "Not yet migrated")
@router.get("/run/{run_id}")
async def run_detail(
run_id: str,
request: Request,
user: UserContext = Depends(require_auth),
):
"""Run detail page with tabs for plan/analysis/artifacts."""
# TODO: Implement
raise HTTPException(501, "Not yet migrated")
@router.get("/run/{run_id}/plan")
async def run_plan(
run_id: str,
request: Request,
user: UserContext = Depends(require_auth),
):
"""Plan visualization as interactive DAG."""
# TODO: Implement
raise HTTPException(501, "Not yet migrated")

35
app/routers/storage.py Normal file
View File

@@ -0,0 +1,35 @@
"""
Storage provider routes for L1 server.
Manages user storage backends (Pinata, web3.storage, local, etc.)
"""
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse
from artdag_common.middleware import UserContext, wants_html
from ..dependencies import require_auth, get_templates
router = APIRouter()
# TODO: Migrate routes from server.py lines 5473-5761
# - GET /storage - List storage providers
# - POST /storage - Add storage provider
# - POST /storage/add - Add via form
# - GET /storage/{storage_id} - Get storage details
# - PATCH /storage/{storage_id} - Update storage
# - DELETE /storage/{storage_id} - Delete storage
# - POST /storage/{storage_id}/test - Test connection
# - GET /storage/type/{provider_type} - Get provider config
@router.get("")
async def list_storage(
request: Request,
user: UserContext = Depends(require_auth),
):
"""List user's storage providers."""
# TODO: Implement
raise HTTPException(501, "Not yet migrated")