docs: add Claude AI assistant rules and configuration
Add comprehensive rule documentation for AI-assisted development covering authentication surfaces, outbound-call safety invariants, and other project conventions to guide Claude's understanding of critical system behaviors.
This commit is contained in:
107
api/tokens.py
Normal file
107
api/tokens.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Owner-only CRUD for personal access tokens (PATs).
|
||||
|
||||
PATs are long-lived bearer tokens for MCP/CLI clients (Claude Desktop, Cline)
|
||||
and scripted API consumers that can't refresh a short-lived Casdoor JWT. The
|
||||
plaintext is shown to the caller exactly once at creation; only its SHA-256
|
||||
hash is stored.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import PAT_PREFIX, OwnerUser, hash_token
|
||||
from db.database import PersonalAccessToken, get_db
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tokens", tags=["tokens"])
|
||||
|
||||
|
||||
class TokenCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
|
||||
|
||||
class TokenOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
token_prefix: str
|
||||
created_at: str | None = None
|
||||
last_used_at: str | None = None
|
||||
expires_at: str | None = None
|
||||
revoked_at: str | None = None
|
||||
|
||||
|
||||
class TokenCreated(TokenOut):
|
||||
token: str = Field(..., description="Plaintext token — shown only once. Store it now.")
|
||||
|
||||
|
||||
def _serialize(pat: PersonalAccessToken) -> dict:
|
||||
return {
|
||||
"id": pat.id,
|
||||
"name": pat.name,
|
||||
"token_prefix": pat.token_prefix,
|
||||
"created_at": pat.created_at.isoformat() if pat.created_at else None,
|
||||
"last_used_at": pat.last_used_at.isoformat() if pat.last_used_at else None,
|
||||
"expires_at": pat.expires_at.isoformat() if pat.expires_at else None,
|
||||
"revoked_at": pat.revoked_at.isoformat() if pat.revoked_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[TokenOut])
|
||||
async def list_tokens(
|
||||
user: OwnerUser,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""List the owner's personal access tokens (no plaintext)."""
|
||||
result = await session.execute(
|
||||
select(PersonalAccessToken)
|
||||
.where(PersonalAccessToken.user_id == user.id)
|
||||
.order_by(PersonalAccessToken.created_at.desc())
|
||||
)
|
||||
return [_serialize(pat) for pat in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("", response_model=TokenCreated, status_code=201)
|
||||
async def create_token(
|
||||
payload: TokenCreate,
|
||||
user: OwnerUser,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Mint a new PAT. The plaintext is returned ONCE in the response."""
|
||||
plaintext = PAT_PREFIX + secrets.token_urlsafe(32)
|
||||
pat = PersonalAccessToken(
|
||||
id=uuid.uuid4().hex,
|
||||
user_id=user.id,
|
||||
name=payload.name,
|
||||
token_hash=hash_token(plaintext),
|
||||
token_prefix=plaintext[: len(PAT_PREFIX) + 4],
|
||||
)
|
||||
session.add(pat)
|
||||
await session.commit()
|
||||
await session.refresh(pat)
|
||||
return {**_serialize(pat), "token": plaintext}
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=204)
|
||||
async def revoke_token(
|
||||
token_id: str,
|
||||
user: OwnerUser,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Soft-revoke a PAT (sets revoked_at)."""
|
||||
result = await session.execute(
|
||||
select(PersonalAccessToken).where(
|
||||
PersonalAccessToken.id == token_id,
|
||||
PersonalAccessToken.user_id == user.id,
|
||||
)
|
||||
)
|
||||
pat = result.scalar_one_or_none()
|
||||
if pat is None:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
if pat.revoked_at is None:
|
||||
pat.revoked_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
Reference in New Issue
Block a user