Compare commits

18 Commits

Author SHA1 Message Date
giles
476e9fa5b0 Route GPU queue to CPU workers when no GPU node available
Add -Q celery,gpu to l1-worker so it picks up GPU-queued tasks
while no GPU node is in the swarm.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:07:36 +00:00
giles
97309d3aad Disable CI — moved to coop/art-dag monorepo 2026-02-24 23:23:29 +00:00
giles
670aa582df Guard user.email access in fragment middleware
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m2s
Handle case where email is None for pre-existing sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:03:40 +00:00
giles
be9fa8e920 Restyle base.html to use coop header with fragments
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m55s
Coop-style sky header with nav-tree, auth-menu, cart-mini fragments.
Art-DAG tabs (Runs, Recipes, etc.) in sub-nav bar with stone pill
styling matching coop patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:00:19 +00:00
giles
82823e393a Pass email to UserContext from OAuth callback
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m31s
The account token endpoint returns user.email as "username".
Store it explicitly so coop fragment middleware can use it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:38:54 +00:00
giles
28a5cc37d0 Consume coop fragments for unified navigation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m2s
Add middleware to fetch nav-tree, auth-menu, and cart-mini fragments
from coop apps. Update base.html to render coop nav with fallback.
Add internal URL env vars for Docker networking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:28:31 +00:00
giles
956da6df2e Skip silent auth check for /internal/ and /oembed paths
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m32s
Fragment and oEmbed endpoints must be accessible without authentication.
The silent auth middleware was returning 302 redirects, causing fragment
fetches from coop apps to silently fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:13:03 +00:00
giles
a3437f0069 Add nav-item + link-card fragments and oEmbed endpoint
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m1s
- nav-item fragment handler with template
- link-card fragment handler with CID-based lookup, friendly names, batch mode
- oEmbed router at GET /oembed for media/recipe/effect/run content
- Fragment templates in app/templates/fragments/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:44:13 +00:00
giles
fc93e27b30 Add fragment router for micro-frontend composition
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m10s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 08:28:01 +00:00
giles
7ec5609aac Clear session cookie when account signals logout via inbox
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m26s
Check did_auth:{device_id} in Redis — if absent while user has
a session cookie, account has logged out. Clear the cookie so
next request triggers prompt=none which won't re-auth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:26:17 +00:00
giles
80b423034d Fix actor_id: use email not display name
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
actor_id must match DB records — always use username (email).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:13:52 +00:00
giles
eaefdd326b Fix healthcheck + external URL in silent auth middleware
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m27s
- Add /health endpoint, update healthcheck to use it
- Use configured base URL instead of internal Docker URL
- Add /health to skip prefixes for silent auth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:06:44 +00:00
giles
e1f13abc7f Fix middleware ordering: device_id must be outermost
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m4s
FastAPI runs the last-registered middleware first on request.
device_id_middleware was inner, so silent_auth_check's early
redirect bypassed it — cookie never set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:50:24 +00:00
giles
b294fd0695 Add AP inbox endpoint + device auth signaling
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m2s
- POST /inbox with HTTP Signature verification
- Device ID cookie tracking + adoption from account
- Silent auth checks local Redis for did_auth signals
- Replaces shared-Redis coupling with AP activity delivery

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:41:33 +00:00
giles
ab3b6b672d Add silent auto-login via prompt=none OAuth check
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
Middleware on every GET checks if user is logged in. If not, does a
silent prompt=none redirect to account. If account has an active
session, login completes invisibly. Otherwise sets a 5-minute cooldown
cookie to avoid redirect loops.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:43:08 +00:00
giles
c7466a2fe8 Fix CI: use github.ref_name for branch in SSH command
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m10s
GITHUB_REF wasn't available inside the remote SSH session.
Use Gitea's template interpolation instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:31:57 +00:00
giles
be263b1398 Deploy on any branch push, not just main
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:30:13 +00:00
giles
49097eef53 Replace L2 JWT auth with OAuth SSO via account.rose-ash.com
- config.py: OAuth settings replace l2_server/l2_domain
- auth.py: full rewrite — login/callback/logout with itsdangerous
  signed state cookies and httpx token exchange
- dependencies.py: remove l2_server assignment, fix redirect path
- home.py: simplify /login to redirect to /auth/login
- base.html: cross-app nav (Blog, Market, Account) + Rose Ash branding
- requirements.txt: add itsdangerous

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:26:17 +00:00
16 changed files with 831 additions and 216 deletions

View File

@@ -1,62 +0,0 @@
name: Build and Deploy
on:
push:
branches: [main]
env:
REGISTRY: registry.rose-ash.com:5000
IMAGE_CPU: celery-l1-server
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
apt-get update && apt-get install -y --no-install-recommends openssh-client
- name: Set up SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Pull latest code on server
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
ssh "root@$DEPLOY_HOST" "
cd /root/art-dag/celery
git fetch origin main
git reset --hard origin/main
"
- name: Build and push image
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
ssh "root@$DEPLOY_HOST" "
cd /root/art-dag/celery
docker build --build-arg CACHEBUST=\$(date +%s) -t ${{ env.REGISTRY }}/${{ env.IMAGE_CPU }}:latest -t ${{ env.REGISTRY }}/${{ env.IMAGE_CPU }}:${{ github.sha }} .
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_CPU }}:latest
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_CPU }}:${{ github.sha }}
"
- name: Deploy stack
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
ssh "root@$DEPLOY_HOST" "
cd /root/art-dag/celery
docker stack deploy -c docker-compose.yml celery
echo 'Waiting for services to update...'
sleep 10
docker stack services celery
"

View File

@@ -4,15 +4,37 @@ Art-DAG L1 Server Application Factory.
Creates and configures the FastAPI application with all routers and middleware. Creates and configures the FastAPI application with all routers and middleware.
""" """
import secrets
import time
from pathlib import Path from pathlib import Path
from urllib.parse import quote
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from artdag_common import create_jinja_env from artdag_common import create_jinja_env
from artdag_common.middleware.auth import get_user_from_cookie
from .config import settings from .config import settings
# Paths that should never trigger a silent auth check
_SKIP_PREFIXES = ("/auth/", "/static/", "/api/", "/ipfs/", "/download/", "/inbox", "/health", "/internal/", "/oembed")
_SILENT_CHECK_COOLDOWN = 300 # 5 minutes
_DEVICE_COOKIE = "artdag_did"
_DEVICE_COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days
# Derive external base URL from oauth_redirect_uri (e.g. https://celery-artdag.rose-ash.com)
_EXTERNAL_BASE = settings.oauth_redirect_uri.rsplit("/auth/callback", 1)[0]
def _external_url(request: Request) -> str:
"""Build external URL from request path + query, using configured base domain."""
url = f"{_EXTERNAL_BASE}{request.url.path}"
if request.url.query:
url += f"?{request.url.query}"
return url
def create_app() -> FastAPI: def create_app() -> FastAPI:
""" """
@@ -38,6 +60,134 @@ def create_app() -> FastAPI:
async def shutdown(): async def shutdown():
await close_db() await close_db()
# Silent auth check — auto-login via prompt=none OAuth
# NOTE: registered BEFORE device_id so device_id is outermost (runs first)
@app.middleware("http")
async def silent_auth_check(request: Request, call_next):
path = request.url.path
if (
request.method != "GET"
or any(path.startswith(p) for p in _SKIP_PREFIXES)
or request.headers.get("hx-request") # skip HTMX
):
return await call_next(request)
# Already logged in — but verify account hasn't logged out
if get_user_from_cookie(request):
device_id = getattr(request.state, "device_id", None)
if device_id:
try:
from .dependencies import get_redis_client
r = get_redis_client()
if not r.get(f"did_auth:{device_id}"):
# Account logged out — clear our cookie
response = await call_next(request)
response.delete_cookie("artdag_session")
response.delete_cookie("pnone_at")
return response
except Exception:
pass
return await call_next(request)
# Check cooldown — don't re-check within 5 minutes
pnone_at = request.cookies.get("pnone_at")
if pnone_at:
try:
pnone_ts = float(pnone_at)
if (time.time() - pnone_ts) < _SILENT_CHECK_COOLDOWN:
# But first check if account signalled a login via inbox delivery
device_id = getattr(request.state, "device_id", None)
if device_id:
try:
from .dependencies import get_redis_client
r = get_redis_client()
auth_ts = r.get(f"did_auth:{device_id}")
if auth_ts and float(auth_ts) > pnone_ts:
# Login happened since our last check — retry
current_url = _external_url(request)
return RedirectResponse(
url=f"/auth/login?prompt=none&next={quote(current_url, safe='')}",
status_code=302,
)
except Exception:
pass
return await call_next(request)
except (ValueError, TypeError):
pass
# Redirect to silent OAuth check
current_url = _external_url(request)
return RedirectResponse(
url=f"/auth/login?prompt=none&next={quote(current_url, safe='')}",
status_code=302,
)
# Device ID middleware — track browser identity across domains
# Registered AFTER silent_auth_check so it's outermost (always runs)
@app.middleware("http")
async def device_id_middleware(request: Request, call_next):
did = request.cookies.get(_DEVICE_COOKIE)
if did:
request.state.device_id = did
request.state._new_device_id = False
else:
request.state.device_id = secrets.token_urlsafe(32)
request.state._new_device_id = True
response = await call_next(request)
if getattr(request.state, "_new_device_id", False):
response.set_cookie(
key=_DEVICE_COOKIE,
value=request.state.device_id,
max_age=_DEVICE_COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=True,
)
return response
# Coop fragment pre-fetch — inject nav-tree, auth-menu, cart-mini into
# request.state for full-page HTML renders. Skips HTMX, API, and
# internal paths. Failures are silent (fragments default to "").
_FRAG_SKIP = ("/auth/", "/api/", "/internal/", "/health", "/oembed",
"/ipfs/", "/download/", "/inbox", "/static/")
@app.middleware("http")
async def coop_fragments_middleware(request: Request, call_next):
path = request.url.path
if (
request.method != "GET"
or any(path.startswith(p) for p in _FRAG_SKIP)
or request.headers.get("hx-request")
or request.headers.get(fragments.FRAGMENT_HEADER)
):
request.state.nav_tree_html = ""
request.state.auth_menu_html = ""
request.state.cart_mini_html = ""
return await call_next(request)
from artdag_common.fragments import fetch_fragments as _fetch_frags
user = get_user_from_cookie(request)
auth_params = {"email": user.email} if user and user.email else {}
nav_params = {"app_name": "artdag", "path": path}
try:
nav_tree_html, auth_menu_html, cart_mini_html = await _fetch_frags([
("blog", "nav-tree", nav_params),
("account", "auth-menu", auth_params or None),
("cart", "cart-mini", None),
])
except Exception:
nav_tree_html = auth_menu_html = cart_mini_html = ""
request.state.nav_tree_html = nav_tree_html
request.state.auth_menu_html = auth_menu_html
request.state.cart_mini_html = cart_mini_html
return await call_next(request)
# Initialize Jinja2 templates # Initialize Jinja2 templates
template_dir = Path(__file__).parent / "templates" template_dir = Path(__file__).parent / "templates"
app.state.templates = create_jinja_env(template_dir) app.state.templates = create_jinja_env(template_dir)
@@ -55,11 +205,14 @@ def create_app() -> FastAPI:
return JSONResponse({"detail": "Not found"}, status_code=404) return JSONResponse({"detail": "Not found"}, status_code=404)
# Include routers # Include routers
from .routers import auth, storage, api, recipes, cache, runs, home, effects from .routers import auth, storage, api, recipes, cache, runs, home, effects, inbox, fragments, oembed
# Home and auth routers (root level) # Home and auth routers (root level)
app.include_router(home.router, tags=["home"]) app.include_router(home.router, tags=["home"])
app.include_router(auth.router, prefix="/auth", tags=["auth"]) app.include_router(auth.router, prefix="/auth", tags=["auth"])
app.include_router(inbox.router, tags=["inbox"])
app.include_router(fragments.router, tags=["fragments"])
app.include_router(oembed.router, tags=["oembed"])
# Feature routers # Feature routers
app.include_router(storage.router, prefix="/storage", tags=["storage"]) app.include_router(storage.router, prefix="/storage", tags=["storage"])

View File

@@ -44,12 +44,24 @@ class Settings:
default_factory=lambda: os.environ.get("IPFS_GATEWAY_URL", "https://ipfs.io/ipfs") default_factory=lambda: os.environ.get("IPFS_GATEWAY_URL", "https://ipfs.io/ipfs")
) )
# L2 Server # OAuth SSO (replaces L2 auth)
l2_server: Optional[str] = field( oauth_authorize_url: str = field(
default_factory=lambda: os.environ.get("L2_SERVER") default_factory=lambda: os.environ.get("OAUTH_AUTHORIZE_URL", "https://account.rose-ash.com/auth/oauth/authorize")
) )
l2_domain: Optional[str] = field( oauth_token_url: str = field(
default_factory=lambda: os.environ.get("L2_DOMAIN") default_factory=lambda: os.environ.get("OAUTH_TOKEN_URL", "https://account.rose-ash.com/auth/oauth/token")
)
oauth_client_id: str = field(
default_factory=lambda: os.environ.get("OAUTH_CLIENT_ID", "artdag")
)
oauth_redirect_uri: str = field(
default_factory=lambda: os.environ.get("OAUTH_REDIRECT_URI", "https://celery-artdag.rose-ash.com/auth/callback")
)
oauth_logout_url: str = field(
default_factory=lambda: os.environ.get("OAUTH_LOGOUT_URL", "https://account.rose-ash.com/auth/sso-logout/")
)
secret_key: str = field(
default_factory=lambda: os.environ.get("SECRET_KEY", "change-me-in-production")
) )
# GPU/Streaming settings # GPU/Streaming settings
@@ -91,7 +103,8 @@ class Settings:
output(f" ipfs_gateway_url: {self.ipfs_gateway_url}") output(f" ipfs_gateway_url: {self.ipfs_gateway_url}")
output(f" ipfs_gateways: {self.ipfs_gateways[:50]}...") output(f" ipfs_gateways: {self.ipfs_gateways[:50]}...")
output(f" streaming_gpu_persist: {self.streaming_gpu_persist}") output(f" streaming_gpu_persist: {self.streaming_gpu_persist}")
output(f" l2_server: {self.l2_server}") output(f" oauth_client_id: {self.oauth_client_id}")
output(f" oauth_authorize_url: {self.oauth_authorize_url}")
output("=" * 60) output("=" * 60)

View File

@@ -64,15 +64,10 @@ async def get_current_user(request: Request) -> Optional[UserContext]:
# Try header first (API clients) # Try header first (API clients)
ctx = get_user_from_header(request) ctx = get_user_from_header(request)
if ctx: if ctx:
# Add l2_server from settings
ctx.l2_server = settings.l2_server
return ctx return ctx
# Fall back to cookie (browser) # Fall back to cookie (browser)
ctx = get_user_from_cookie(request) return get_user_from_cookie(request)
if ctx:
ctx.l2_server = settings.l2_server
return ctx
async def require_auth(request: Request) -> UserContext: async def require_auth(request: Request) -> UserContext:
@@ -90,7 +85,7 @@ async def require_auth(request: Request) -> UserContext:
if "text/html" in accept: if "text/html" in accept:
raise HTTPException( raise HTTPException(
status_code=302, status_code=302,
headers={"Location": "/login"} headers={"Location": "/auth/login"}
) )
raise HTTPException(status_code=401, detail="Authentication required") raise HTTPException(status_code=401, detail="Authentication required")
return ctx return ctx

View File

@@ -1,122 +1,165 @@
""" """
Authentication routes for L1 server. Authentication routes — OAuth2 authorization code flow via account.rose-ash.com.
L1 doesn't handle login directly - users log in at their L2 server. GET /auth/login — redirect to account OAuth authorize
Token is passed via URL from L2 redirect, then L1 sets its own cookie. GET /auth/callback — exchange code for user info, set session cookie
GET /auth/logout — clear cookie, redirect through account SSO logout
""" """
from fastapi import APIRouter, Depends, HTTPException, Request import secrets
import time
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from itsdangerous import URLSafeSerializer
from pydantic import BaseModel
from ..dependencies import get_redis_client from artdag_common.middleware.auth import UserContext, set_auth_cookie, clear_auth_cookie
from ..services.auth_service import AuthService
from ..config import settings
router = APIRouter() router = APIRouter()
security = HTTPBearer(auto_error=False)
_signer = None
def get_auth_service(): def _get_signer() -> URLSafeSerializer:
"""Get auth service instance.""" global _signer
return AuthService(get_redis_client()) if _signer is None:
_signer = URLSafeSerializer(settings.secret_key, salt="oauth-state")
return _signer
class RevokeUserRequest(BaseModel): @router.get("/login")
"""Request to revoke all tokens for a user.""" async def login(request: Request):
username: str """Store state + next in signed cookie, redirect to account OAuth authorize."""
l2_server: str next_url = request.query_params.get("next", "/")
prompt = request.query_params.get("prompt", "")
state = secrets.token_urlsafe(32)
signer = _get_signer()
state_payload = signer.dumps({"state": state, "next": next_url, "prompt": prompt})
@router.get("") device_id = getattr(request.state, "device_id", "")
async def auth_callback( authorize_url = (
request: Request, f"{settings.oauth_authorize_url}"
auth_token: str = None, f"?client_id={settings.oauth_client_id}"
auth_service: AuthService = Depends(get_auth_service), f"&redirect_uri={settings.oauth_redirect_uri}"
): f"&device_id={device_id}"
""" f"&state={state}"
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.
"""
if not auth_token:
return RedirectResponse(url="/", status_code=302)
# Verify the token is valid
ctx = await auth_service.verify_token_with_l2(auth_token)
if not ctx:
return RedirectResponse(url="/", status_code=302)
# Register token for this user (for revocation by username later)
auth_service.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
) )
if prompt:
authorize_url += f"&prompt={prompt}"
response = RedirectResponse(url=authorize_url, status_code=302)
response.set_cookie(
key="oauth_state",
value=state_payload,
max_age=600, # 10 minutes
httponly=True,
samesite="lax",
secure=True,
)
return response
@router.get("/callback")
async def callback(request: Request):
"""Validate state, exchange code via token endpoint, set session cookie."""
code = request.query_params.get("code", "")
state = request.query_params.get("state", "")
error = request.query_params.get("error", "")
account_did = request.query_params.get("account_did", "")
# Adopt account's device ID as our own (one identity across all apps)
if account_did:
request.state.device_id = account_did
request.state._new_device_id = True # device_id middleware will set cookie
# Recover state from signed cookie
state_cookie = request.cookies.get("oauth_state", "")
signer = _get_signer()
try:
payload = signer.loads(state_cookie) if state_cookie else {}
except Exception:
payload = {}
next_url = payload.get("next", "/")
# Handle prompt=none rejection (user not logged in on account)
if error == "login_required":
response = RedirectResponse(url=next_url, status_code=302)
response.delete_cookie("oauth_state")
# Set cooldown cookie — don't re-check for 5 minutes
response.set_cookie(
key="pnone_at",
value=str(time.time()),
max_age=300,
httponly=True,
samesite="lax",
secure=True,
)
# Set device cookie if adopted
if account_did:
response.set_cookie(
key="artdag_did",
value=account_did,
max_age=30 * 24 * 3600,
httponly=True,
samesite="lax",
secure=True,
)
return response
# Normal callback — validate state + code
if not state_cookie or not code or not state:
return RedirectResponse(url="/", status_code=302)
if payload.get("state") != state:
return RedirectResponse(url="/", status_code=302)
# Exchange code for user info via account's token endpoint
async with httpx.AsyncClient(timeout=10) as client:
try:
resp = await client.post(
settings.oauth_token_url,
json={
"code": code,
"client_id": settings.oauth_client_id,
"redirect_uri": settings.oauth_redirect_uri,
},
)
except httpx.HTTPError:
return RedirectResponse(url="/", status_code=302)
if resp.status_code != 200:
return RedirectResponse(url="/", status_code=302)
data = resp.json()
if "error" in data:
return RedirectResponse(url="/", status_code=302)
# Map OAuth response to artdag UserContext
# Note: account token endpoint returns user.email as "username"
display_name = data.get("display_name", "")
username = data.get("username", "")
email = username # OAuth response "username" is the user's email
actor_id = f"@{username}"
user = UserContext(username=username, actor_id=actor_id, email=email)
response = RedirectResponse(url=next_url, status_code=302)
set_auth_cookie(response, user)
response.delete_cookie("oauth_state")
response.delete_cookie("pnone_at")
return response return response
@router.get("/logout") @router.get("/logout")
async def logout(): async def logout():
""" """Clear session cookie, redirect through account SSO logout."""
Logout - clear local cookie and redirect to home. response = RedirectResponse(url=settings.oauth_logout_url, status_code=302)
clear_auth_cookie(response)
Note: This only logs out of L1. User should also logout from L2. response.delete_cookie("oauth_state")
""" response.delete_cookie("pnone_at")
response = RedirectResponse(url="/", status_code=302)
response.delete_cookie("auth_token")
return response return response
@router.post("/revoke")
async def revoke_token(
credentials: HTTPAuthorizationCredentials = Depends(security),
auth_service: AuthService = Depends(get_auth_service),
):
"""
Revoke a token. Called by L2 when user logs out.
The token to revoke is passed in the Authorization header.
"""
if not credentials:
raise HTTPException(401, "No token provided")
token = credentials.credentials
# Verify token is valid before revoking (ensures caller has the token)
ctx = auth_service.get_user_context_from_token(token)
if not ctx:
raise HTTPException(401, "Invalid token")
# Revoke the token
newly_revoked = auth_service.revoke_token(token)
return {"revoked": True, "newly_revoked": newly_revoked}
@router.post("/revoke-user")
async def revoke_user_tokens(
request: RevokeUserRequest,
auth_service: AuthService = Depends(get_auth_service),
):
"""
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.
"""
# Revoke all tokens registered for this user
count = auth_service.revoke_all_user_tokens(request.username)
return {
"revoked": True,
"tokens_revoked": count,
"username": request.username
}

143
app/routers/fragments.py Normal file
View File

@@ -0,0 +1,143 @@
"""
Art-DAG fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/{type}`` for consumption
by coop apps via the fragment client.
"""
import os
from fastapi import APIRouter, Request, Response
router = APIRouter()
# Registry of fragment handlers: type -> async callable(request) returning HTML str
_handlers: dict[str, object] = {}
FRAGMENT_HEADER = "X-Fragment-Request"
@router.get("/internal/fragments/{fragment_type}")
async def get_fragment(fragment_type: str, request: Request):
if not request.headers.get(FRAGMENT_HEADER):
return Response(content="", status_code=403)
handler = _handlers.get(fragment_type)
if handler is None:
return Response(content="", media_type="text/html", status_code=200)
html = await handler(request)
return Response(content=html, media_type="text/html", status_code=200)
# --- nav-item fragment ---
async def _nav_item_handler(request: Request) -> str:
from artdag_common import render_fragment
templates = request.app.state.templates
artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com")
return render_fragment(templates, "fragments/nav_item.html", artdag_url=artdag_url)
_handlers["nav-item"] = _nav_item_handler
# --- link-card fragment ---
async def _link_card_handler(request: Request) -> str:
from artdag_common import render_fragment
import database
templates = request.app.state.templates
cid = request.query_params.get("cid", "")
content_type = request.query_params.get("type", "media")
slug = request.query_params.get("slug", "")
keys_raw = request.query_params.get("keys", "")
# Batch mode: return multiple cards separated by markers
if keys_raw:
keys = [k.strip() for k in keys_raw.split(",") if k.strip()]
parts = []
for key in keys:
parts.append(f"<!-- fragment:{key} -->")
card_html = await _render_single_link_card(
templates, key, content_type,
)
parts.append(card_html)
return "\n".join(parts)
# Single mode: use cid or slug
lookup_cid = cid or slug
if not lookup_cid:
return ""
return await _render_single_link_card(templates, lookup_cid, content_type)
async def _render_single_link_card(templates, cid: str, content_type: str) -> str:
import database
from artdag_common import render_fragment
if not cid:
return ""
artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com")
# Try item_types first (has metadata)
item = await database.get_item_types(cid)
# get_item_types returns a list; pick best match for content_type
meta = None
if item:
for it in item:
if it.get("type") == content_type:
meta = it
break
if not meta:
meta = item[0]
# Try friendly name for display
friendly = None
if meta and meta.get("actor_id"):
friendly = await database.get_friendly_name_by_cid(meta["actor_id"], cid)
# Try run cache if type is "run"
run = None
if content_type == "run":
run = await database.get_run_cache(cid)
title = ""
description = ""
link = ""
if friendly:
title = friendly.get("display_name") or friendly.get("base_name", cid[:12])
elif meta:
title = meta.get("filename") or meta.get("description", cid[:12])
elif run:
title = f"Run {cid[:12]}"
else:
title = cid[:16]
if meta:
description = meta.get("description", "")
if content_type == "run":
link = f"{artdag_url}/runs/{cid}"
elif content_type == "recipe":
link = f"{artdag_url}/recipes/{cid}"
elif content_type == "effect":
link = f"{artdag_url}/effects/{cid}"
else:
link = f"{artdag_url}/cache/{cid}"
return render_fragment(
templates, "fragments/link_card.html",
title=title,
description=description,
link=link,
cid=cid,
content_type=content_type,
artdag_url=artdag_url,
)
_handlers["link-card"] = _link_card_handler

View File

@@ -16,6 +16,12 @@ from ..dependencies import get_templates, get_current_user
router = APIRouter() router = APIRouter()
@router.get("/health")
async def health():
"""Health check endpoint — always returns 200."""
return {"status": "ok"}
async def get_user_stats(actor_id: str) -> dict: async def get_user_stats(actor_id: str) -> dict:
"""Get stats for a user.""" """Get stats for a user."""
import database import database
@@ -227,23 +233,8 @@ async def home(request: Request):
@router.get("/login") @router.get("/login")
async def login_redirect(request: Request): async def login_redirect(request: Request):
""" """Redirect to OAuth login flow."""
Redirect to L2 for login. return RedirectResponse(url="/auth/login", status_code=302)
"""
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
)
# Client tarball path # Client tarball path

125
app/routers/inbox.py Normal file
View File

@@ -0,0 +1,125 @@
"""AP-style inbox endpoint for receiving signed activities from the coop.
POST /inbox — verify HTTP Signature, dispatch by activity type.
"""
from __future__ import annotations
import logging
import time
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from ..dependencies import get_redis_client
from ..utils.http_signatures import verify_request_signature, parse_key_id
log = logging.getLogger(__name__)
router = APIRouter()
# Cache fetched public keys in Redis for 24 hours
_KEY_CACHE_TTL = 86400
async def _fetch_actor_public_key(actor_url: str) -> str | None:
"""Fetch an actor's public key, with Redis caching."""
redis = get_redis_client()
cache_key = f"actor_pubkey:{actor_url}"
# Check cache
cached = redis.get(cache_key)
if cached:
return cached
# Fetch actor JSON
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
actor_url,
headers={"Accept": "application/activity+json, application/ld+json"},
)
if resp.status_code != 200:
log.warning("Failed to fetch actor %s: %d", actor_url, resp.status_code)
return None
data = resp.json()
except Exception:
log.warning("Error fetching actor %s", actor_url, exc_info=True)
return None
pub_key_pem = (data.get("publicKey") or {}).get("publicKeyPem")
if not pub_key_pem:
log.warning("No publicKey in actor %s", actor_url)
return None
# Cache it
redis.set(cache_key, pub_key_pem, ex=_KEY_CACHE_TTL)
return pub_key_pem
@router.post("/inbox")
async def inbox(request: Request):
"""Receive signed AP activities from the coop platform."""
sig_header = request.headers.get("signature", "")
if not sig_header:
return JSONResponse({"error": "missing signature"}, status_code=401)
# Read body
body = await request.body()
# Verify HTTP Signature
actor_url = parse_key_id(sig_header)
if not actor_url:
return JSONResponse({"error": "invalid keyId"}, status_code=401)
pub_key = await _fetch_actor_public_key(actor_url)
if not pub_key:
return JSONResponse({"error": "could not fetch public key"}, status_code=401)
req_headers = dict(request.headers)
path = request.url.path
valid = verify_request_signature(
public_key_pem=pub_key,
signature_header=sig_header,
method="POST",
path=path,
headers=req_headers,
)
if not valid:
log.warning("Invalid signature from %s", actor_url)
return JSONResponse({"error": "invalid signature"}, status_code=401)
# Parse and dispatch
try:
activity = await request.json()
except Exception:
return JSONResponse({"error": "invalid json"}, status_code=400)
activity_type = activity.get("type", "")
log.info("Inbox received: %s from %s", activity_type, actor_url)
if activity_type == "rose:DeviceAuth":
_handle_device_auth(activity)
# Always 202 — AP convention
return JSONResponse({"status": "accepted"}, status_code=202)
def _handle_device_auth(activity: dict) -> None:
"""Set or delete did_auth:{device_id} in local Redis."""
obj = activity.get("object", {})
device_id = obj.get("device_id", "")
action = obj.get("action", "")
if not device_id:
log.warning("rose:DeviceAuth missing device_id")
return
redis = get_redis_client()
if action == "login":
redis.set(f"did_auth:{device_id}", str(time.time()), ex=30 * 24 * 3600)
log.info("did_auth set for device %s...", device_id[:16])
elif action == "logout":
redis.delete(f"did_auth:{device_id}")
log.info("did_auth cleared for device %s...", device_id[:16])
else:
log.warning("rose:DeviceAuth unknown action: %s", action)

74
app/routers/oembed.py Normal file
View File

@@ -0,0 +1,74 @@
"""Art-DAG oEmbed endpoint.
Returns oEmbed JSON responses for Art-DAG content (media, recipes, effects, runs).
"""
import os
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
router = APIRouter()
@router.get("/oembed")
async def oembed(request: Request):
url = request.query_params.get("url", "")
if not url:
return JSONResponse({"error": "url parameter required"}, status_code=400)
# Parse URL to extract content type and CID
# URL patterns: /cache/{cid}, /recipes/{cid}, /effects/{cid}, /runs/{cid}
from urllib.parse import urlparse
parsed = urlparse(url)
parts = [p for p in parsed.path.strip("/").split("/") if p]
if len(parts) < 2:
return JSONResponse({"error": "could not parse content URL"}, status_code=404)
content_type = parts[0].rstrip("s") # recipes -> recipe, runs -> run
cid = parts[1]
import database
title = cid[:16]
thumbnail_url = None
# Look up metadata
items = await database.get_item_types(cid)
if items:
meta = items[0]
title = meta.get("filename") or meta.get("description") or title
# Try friendly name
actor_id = meta.get("actor_id")
if actor_id:
friendly = await database.get_friendly_name_by_cid(actor_id, cid)
if friendly:
title = friendly.get("display_name") or friendly.get("base_name", title)
# Media items get a thumbnail
if meta.get("type") == "media":
artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com")
thumbnail_url = f"{artdag_url}/cache/{cid}/raw"
elif content_type == "run":
run = await database.get_run_cache(cid)
if run:
title = f"Run {cid[:12]}"
artdag_url = os.getenv("APP_URL_ARTDAG", "https://celery-artdag.rose-ash.com")
resp = {
"version": "1.0",
"type": "link",
"title": title,
"provider_name": "art-dag",
"provider_url": artdag_url,
"url": url,
}
if thumbnail_url:
resp["thumbnail_url"] = thumbnail_url
return JSONResponse(resp)

View File

@@ -1,25 +1,46 @@
{% extends "_base.html" %} {% extends "_base.html" %}
{% block brand %}Art-DAG L1{% endblock %} {% block brand %}
<a href="https://blog.rose-ash.com/" class="no-underline text-stone-900">Rose Ash</a>
{% block nav_items %} <span class="text-stone-400 mx-1">|</span>
<nav class="flex items-center space-x-6"> <a href="/" class="no-underline text-stone-900">Art-DAG</a>
<a href="/runs" class="text-gray-300 hover:text-white {% if active_tab == 'runs' %}text-white font-medium{% endif %}">Runs{% if nav_counts and nav_counts.runs %} ({{ nav_counts.runs }}){% endif %}</a>
<a href="/recipes" class="text-gray-300 hover:text-white {% if active_tab == 'recipes' %}text-white font-medium{% endif %}">Recipes{% if nav_counts and nav_counts.recipes %} ({{ nav_counts.recipes }}){% endif %}</a>
<a href="/effects" class="text-gray-300 hover:text-white {% if active_tab == 'effects' %}text-white font-medium{% endif %}">Effects{% if nav_counts and nav_counts.effects %} ({{ nav_counts.effects }}){% endif %}</a>
<a href="/media" class="text-gray-300 hover:text-white {% if active_tab == 'media' %}text-white font-medium{% endif %}">Media{% if nav_counts and nav_counts.media %} ({{ nav_counts.media }}){% endif %}</a>
<a href="/storage" class="text-gray-300 hover:text-white {% if active_tab == 'storage' %}text-white font-medium{% endif %}">Storage{% if nav_counts and nav_counts.storage %} ({{ nav_counts.storage }}){% endif %}</a>
<a href="/download/client" class="text-gray-300 hover:text-white" title="Download CLI client">Client</a>
</nav>
{% endblock %} {% endblock %}
{% block nav_right %} {% block cart_mini %}
{% if user %} {% if request and request.state.cart_mini_html %}
<div class="flex items-center space-x-4"> {{ request.state.cart_mini_html | safe }}
<span class="text-gray-400">{{ user.username }}</span>
<a href="/auth/logout" class="text-gray-300 hover:text-white">Logout</a>
</div>
{% else %}
<a href="/login" class="text-gray-300 hover:text-white">Login</a>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block nav_tree %}
{% if request and request.state.nav_tree_html %}
{{ request.state.nav_tree_html | safe }}
{% endif %}
{% endblock %}
{% block auth_menu %}
{% if request and request.state.auth_menu_html %}
{{ request.state.auth_menu_html | safe }}
{% endif %}
{% endblock %}
{% block auth_menu_mobile %}
{% if request and request.state.auth_menu_html %}
{{ request.state.auth_menu_html | safe }}
{% endif %}
{% endblock %}
{% block sub_nav %}
<div class="bg-stone-200 border-b border-stone-300">
<div class="max-w-screen-2xl mx-auto px-4">
<nav class="flex items-center gap-4 py-2 text-sm overflow-x-auto no-scrollbar">
<a href="/runs" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'runs' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Runs{% if nav_counts and nav_counts.runs %} ({{ nav_counts.runs }}){% endif %}</a>
<a href="/recipes" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'recipes' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Recipes{% if nav_counts and nav_counts.recipes %} ({{ nav_counts.recipes }}){% endif %}</a>
<a href="/effects" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'effects' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Effects{% if nav_counts and nav_counts.effects %} ({{ nav_counts.effects }}){% endif %}</a>
<a href="/media" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'media' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Media{% if nav_counts and nav_counts.media %} ({{ nav_counts.media }}){% endif %}</a>
<a href="/storage" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'storage' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Storage{% if nav_counts and nav_counts.storage %} ({{ nav_counts.storage }}){% endif %}</a>
<a href="/download/client" class="whitespace-nowrap px-3 py-1.5 rounded text-stone-700 hover:bg-stone-300" title="Download CLI client">Client</a>
</nav>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,22 @@
<a href="{{ link }}" class="block rounded border border-stone-200 bg-white hover:bg-stone-50 transition-colors no-underline" data-fragment="link-card" data-app="artdag" data-hx-disable>
<div class="flex flex-row items-center gap-3 p-3">
<div class="flex-shrink-0 w-10 h-10 rounded bg-stone-100 flex items-center justify-center text-stone-500">
{% if content_type == "recipe" %}
<i class="fas fa-scroll text-sm"></i>
{% elif content_type == "effect" %}
<i class="fas fa-magic text-sm"></i>
{% elif content_type == "run" %}
<i class="fas fa-play-circle text-sm"></i>
{% else %}
<i class="fas fa-cube text-sm"></i>
{% endif %}
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-stone-900 text-sm truncate">{{ title }}</div>
{% if description %}
<div class="text-xs text-stone-500 clamp-2">{{ description }}</div>
{% endif %}
<div class="text-xs text-stone-400 mt-0.5">{{ content_type }} &middot; {{ cid[:12] }}&hellip;</div>
</div>
</div>
</a>

View File

@@ -0,0 +1,7 @@
<div class="relative nav-group">
<a href="{{ artdag_url }}"
class="justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3"
data-hx-disable>
<i class="fas fa-project-diagram text-sm"></i> art-dag
</a>
</div>

0
app/utils/__init__.py Normal file
View File

View File

@@ -0,0 +1,84 @@
"""HTTP Signature verification for incoming AP-style inbox requests.
Implements the same RSA-SHA256 / PKCS1v15 scheme used by the coop's
shared/utils/http_signatures.py, but only the verification side.
"""
from __future__ import annotations
import base64
import re
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
def verify_request_signature(
public_key_pem: str,
signature_header: str,
method: str,
path: str,
headers: dict[str, str],
) -> bool:
"""Verify an incoming HTTP Signature.
Args:
public_key_pem: PEM-encoded public key of the sender.
signature_header: Value of the ``Signature`` header.
method: HTTP method (GET, POST, etc.).
path: Request path (e.g. ``/inbox``).
headers: All request headers (case-insensitive keys).
Returns:
True if the signature is valid.
"""
parts = _parse_signature_header(signature_header)
signed_headers = parts.get("headers", "date").split()
signature_b64 = parts.get("signature", "")
# Reconstruct the signed string
lc_headers = {k.lower(): v for k, v in headers.items()}
lines: list[str] = []
for h in signed_headers:
if h == "(request-target)":
lines.append(f"(request-target): {method.lower()} {path}")
else:
lines.append(f"{h}: {lc_headers.get(h, '')}")
signed_string = "\n".join(lines)
public_key = serialization.load_pem_public_key(public_key_pem.encode())
try:
public_key.verify(
base64.b64decode(signature_b64),
signed_string.encode(),
padding.PKCS1v15(),
hashes.SHA256(),
)
return True
except Exception:
return False
def parse_key_id(signature_header: str) -> str:
"""Extract the keyId from a Signature header.
keyId is typically ``https://domain/users/username#main-key``.
Returns the actor URL (strips ``#main-key``).
"""
parts = _parse_signature_header(signature_header)
key_id = parts.get("keyId", "")
return re.sub(r"#.*$", "", key_id)
def _parse_signature_header(header: str) -> dict[str, str]:
"""Parse a Signature header into its component parts."""
parts: dict[str, str] = {}
for part in header.split(","):
part = part.strip()
eq = part.find("=")
if eq < 0:
continue
key = part[:eq]
val = part[eq + 1:].strip('"')
parts[key] = val
return parts

View File

@@ -73,10 +73,14 @@ services:
# IPFS_API multiaddr - used for all IPFS operations (add, cat, pin) # IPFS_API multiaddr - used for all IPFS operations (add, cat, pin)
- IPFS_API=/dns/ipfs/tcp/5001 - IPFS_API=/dns/ipfs/tcp/5001
- CACHE_DIR=/data/cache - CACHE_DIR=/data/cache
# Coop app internal URLs for fragment composition
- INTERNAL_URL_BLOG=http://blog:8000
- INTERNAL_URL_CART=http://cart:8000
- INTERNAL_URL_ACCOUNT=http://account:8000
# DATABASE_URL, ADMIN_TOKEN, ARTDAG_CLUSTER_KEY, # DATABASE_URL, ADMIN_TOKEN, ARTDAG_CLUSTER_KEY,
# L2_SERVER, L2_DOMAIN, IPFS_GATEWAY_URL from .env file # L2_SERVER, L2_DOMAIN, IPFS_GATEWAY_URL from .env file
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health')"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 3
@@ -102,7 +106,7 @@ services:
l1-worker: l1-worker:
image: registry.rose-ash.com:5000/celery-l1-server:latest image: registry.rose-ash.com:5000/celery-l1-server:latest
command: sh -c "find /app -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null; celery -A celery_app worker --loglevel=info -E" command: sh -c "find /app -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null; celery -A celery_app worker --loglevel=info -E -Q celery,gpu"
env_file: env_file:
- .env - .env
environment: environment:

View File

@@ -2,6 +2,8 @@ celery[redis]>=5.3.0
redis>=5.0.0 redis>=5.0.0
requests>=2.31.0 requests>=2.31.0
httpx>=0.27.0 httpx>=0.27.0
itsdangerous>=2.0
cryptography>=41.0
fastapi>=0.109.0 fastapi>=0.109.0
uvicorn>=0.27.0 uvicorn>=0.27.0
python-multipart>=0.0.6 python-multipart>=0.0.6