"""Cookie-authenticated PAT administration; MCP bearer tokens grant no access here.""" from datetime import timezone from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import func, select from ..models import Account, MCPToken, now from ..security import require_auth from .auth import create_token router = APIRouter(prefix="/api/v1/mcp-tokens", tags=["mcp-tokens"], dependencies=[Depends(require_auth)]) class TokenInput(BaseModel): model_config = ConfigDict(extra="forbid") name: str = Field(min_length=1, max_length=100) days: int = Field(default=90, ge=1, le=365, strict=True) scopes: list[str] = Field(default_factory=lambda: ["research:read"], max_length=4) def token_output(row, account): """Return public metadata only, including whether the current binding is usable.""" def timestamp(value): return value.replace(tzinfo=value.tzinfo or timezone.utc) if value else None expires = timestamp(row.expires_at) status = ( "revoked" if row.revoked_at else "expired" if expires <= now() else "invalid_binding" if not account or account.wq_user_id != row.wq_user_id else "active" ) return { "id": row.id, "name": row.name, "scopes": row.scopes, "created_at": timestamp(row.created_at), "expires_at": expires, "revoked_at": timestamp(row.revoked_at), "status": status, } @router.get("") async def list_tokens(request: Request, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0)): async with request.app.state.sessions() as db: account = await db.get(Account, 1) owned = (MCPToken.admin_id == 1, MCPToken.account_id == 1) total = await db.scalar(select(func.count()).select_from(MCPToken).where(*owned)) rows = await db.scalars(select(MCPToken).where(*owned).order_by( MCPToken.created_at.desc(), MCPToken.id.desc()).offset(offset).limit(limit)) return { "items": [token_output(row, account) for row in rows], "total": total, "limit": limit, "offset": offset, "has_more": offset + limit < total, "enabled": request.app.state.settings.mcp_enabled, "endpoint": request.app.state.settings.public_origin.rstrip("/") + "/api/v1/mcp/", "can_create": bool(account and account.wq_user_id), } @router.post("", status_code=201) async def issue_token(body: TokenInput, request: Request): # The existing single-admin browser session is the authority, never request-supplied IDs. async with request.app.state.sessions.begin() as db: try: row, secret = await create_token(db, body.name, body.scopes, body.days) except ValueError as exc: raise HTTPException(422, str(exc)) from exc result = token_output(row, await db.get(Account, 1)) # Do not expose the secret until the transaction successfully commits. return {**result, "token": secret} @router.post("/{token_id}/revoke") async def revoke_token(token_id: str, request: Request): async with request.app.state.sessions.begin() as db: row = await db.scalar(select(MCPToken).where( MCPToken.id == token_id, MCPToken.admin_id == 1, MCPToken.account_id == 1, ).with_for_update()) if not row: raise HTTPException(404, "MCP Key 不存在") row.revoked_at = row.revoked_at or now() result = token_output(row, await db.get(Account, 1)) return result