This persists work that had been living only in the cookbook docker
container's writable layer — never committed to the host source. Brought
back to git intact, app.py registration re-applied surgically on top of
current main (not the older container copy, which would have regressed
the Windows MIME fix, asynccontextmanager lifespan, and webhook auth
exempts).
routes/codex_routes.py (new):
- GET /api/codex/capabilities — what this Odysseus exposes.
- GET /api/codex/plugin.zip — downloads integrations/codex as a zip.
- GET /api/codex/todos — scope-gated todos:read|write.
- POST /api/codex/todos — scope-gated todos:write.
- GET /api/codex/emails — scope-gated email:read|draft|send.
- GET /api/codex/emails/{uid} — single-message fetch.
- _scope_owner() enforces api_token scopes before touching user data.
routes/api_token_routes.py (+103 lines):
- Adds Codex-token-specific issuance + revocation paths.
integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip):
- README.md — install instructions.
- .codex-plugin/plugin.json — Codex plugin manifest.
- scripts/odysseus_api.py — Python client used by the skill.
- skills/odysseus/SKILL.md — Codex skill definition.
static/js/settings.js (+253 lines):
- New "Codex Agent" option in the Integrations dropdown.
- Add / edit panel with plugin-bundle download link + curl-with-token
install instructions per agent.
app.py:
- 7-line surgical change: capture email_router = setup_email_routes()
and register setup_codex_routes(email_router=email_router) after the
email module so the Codex routes can borrow its helpers.
189 lines
6.3 KiB
Python
189 lines
6.3 KiB
Python
"""API Token management routes — /api/tokens/*."""
|
|
|
|
import secrets
|
|
import uuid
|
|
|
|
import bcrypt
|
|
from fastapi import APIRouter, HTTPException, Request, Form
|
|
|
|
from core.database import get_db_session, ApiToken
|
|
from core.middleware import require_admin
|
|
from src.auth_helpers import get_current_user
|
|
|
|
MAX_NAME_LEN = 100
|
|
DEFAULT_SCOPES = "chat"
|
|
ALLOWED_SCOPES = {
|
|
"chat",
|
|
"todos:read",
|
|
"todos:write",
|
|
"documents:read",
|
|
"documents:write",
|
|
"email:read",
|
|
"email:draft",
|
|
"email:send",
|
|
"calendar:read",
|
|
"calendar:write",
|
|
"memory:read",
|
|
"memory:write",
|
|
}
|
|
TOKEN_PROFILES = {
|
|
"chat": ["chat"],
|
|
"codex_todos": ["todos:read", "todos:write"],
|
|
"codex_email_drafts": ["email:read", "email:draft", "documents:read", "documents:write"],
|
|
}
|
|
|
|
|
|
def _normalize_scopes(scopes: str | list[str] | None = None, profile: str | None = None) -> list[str]:
|
|
profile = profile if isinstance(profile, str) else None
|
|
profile_key = (profile or "").strip()
|
|
if profile_key:
|
|
if profile_key not in TOKEN_PROFILES:
|
|
raise HTTPException(400, "Unknown token profile")
|
|
requested = list(TOKEN_PROFILES[profile_key])
|
|
elif isinstance(scopes, list):
|
|
requested = [str(s).strip() for s in scopes if str(s).strip()]
|
|
elif isinstance(scopes, str) and scopes:
|
|
requested = [s.strip() for s in scopes.replace(" ", ",").split(",") if s.strip()]
|
|
else:
|
|
requested = [DEFAULT_SCOPES]
|
|
|
|
normalized = []
|
|
for scope in requested:
|
|
if scope not in ALLOWED_SCOPES:
|
|
raise HTTPException(400, f"Unknown token scope: {scope}")
|
|
if scope not in normalized:
|
|
normalized.append(scope)
|
|
|
|
def ensure_before(write_scope: str, read_scope: str):
|
|
if write_scope not in normalized or read_scope in normalized:
|
|
return
|
|
idx = normalized.index(write_scope)
|
|
normalized.insert(idx, read_scope)
|
|
|
|
ensure_before("todos:write", "todos:read")
|
|
ensure_before("documents:write", "documents:read")
|
|
ensure_before("calendar:write", "calendar:read")
|
|
ensure_before("memory:write", "memory:read")
|
|
ensure_before("email:draft", "email:read")
|
|
|
|
return normalized or [DEFAULT_SCOPES]
|
|
|
|
|
|
def setup_api_token_routes() -> APIRouter:
|
|
router = APIRouter(prefix="/api", tags=["api_tokens"])
|
|
|
|
@router.get("/tokens")
|
|
def list_tokens(request: Request):
|
|
require_admin(request)
|
|
with get_db_session() as db:
|
|
tokens = db.query(ApiToken).all()
|
|
return [
|
|
{
|
|
"id": t.id,
|
|
"name": t.name,
|
|
"owner": getattr(t, "owner", None),
|
|
"token_prefix": t.token_prefix,
|
|
"scopes": [s.strip() for s in (getattr(t, "scopes", "") or DEFAULT_SCOPES).split(",") if s.strip()],
|
|
"is_active": t.is_active,
|
|
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
|
|
"created_at": t.created_at.isoformat() if t.created_at else None,
|
|
}
|
|
for t in tokens
|
|
]
|
|
|
|
def _invalidate_cache(request: Request):
|
|
"""Tell the auth middleware its cached token map is stale."""
|
|
try:
|
|
invalidator = getattr(request.app.state, "invalidate_token_cache", None)
|
|
if invalidator:
|
|
invalidator()
|
|
except Exception:
|
|
pass
|
|
|
|
@router.get("/tokens/profiles")
|
|
def token_profiles(request: Request):
|
|
require_admin(request)
|
|
return {
|
|
"profiles": TOKEN_PROFILES,
|
|
"allowed_scopes": sorted(ALLOWED_SCOPES),
|
|
}
|
|
|
|
@router.post("/tokens")
|
|
def create_token(
|
|
request: Request,
|
|
name: str = Form(""),
|
|
scopes: str = Form(None),
|
|
profile: str = Form(None),
|
|
):
|
|
require_admin(request)
|
|
name = name.strip()[:MAX_NAME_LEN]
|
|
if not name:
|
|
raise HTTPException(400, "Token name is required")
|
|
owner = get_current_user(request)
|
|
scope_list = _normalize_scopes(scopes, profile)
|
|
scopes_value = ",".join(scope_list)
|
|
|
|
raw_token = "ody_" + secrets.token_urlsafe(32)
|
|
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
|
|
token_id = str(uuid.uuid4())[:8]
|
|
|
|
with get_db_session() as db:
|
|
db.add(ApiToken(
|
|
id=token_id,
|
|
owner=owner,
|
|
name=name,
|
|
token_hash=token_hash,
|
|
token_prefix=raw_token[:8],
|
|
scopes=scopes_value,
|
|
is_active=True,
|
|
))
|
|
_invalidate_cache(request)
|
|
|
|
return {
|
|
"id": token_id,
|
|
"name": name,
|
|
"owner": owner,
|
|
"token": raw_token,
|
|
"token_prefix": raw_token[:8],
|
|
"scopes": scope_list,
|
|
}
|
|
|
|
@router.patch("/tokens/{token_id}")
|
|
async def update_token(request: Request, token_id: str):
|
|
require_admin(request)
|
|
try:
|
|
payload = await request.json()
|
|
except Exception:
|
|
payload = {}
|
|
scope_list = _normalize_scopes(payload.get("scopes"))
|
|
scopes_value = ",".join(scope_list)
|
|
with get_db_session() as db:
|
|
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
|
|
if not token:
|
|
raise HTTPException(404, "Token not found")
|
|
if isinstance(payload.get("name"), str) and payload["name"].strip():
|
|
token.name = payload["name"].strip()[:MAX_NAME_LEN]
|
|
token.scopes = scopes_value
|
|
db.add(token)
|
|
response = {
|
|
"id": token_id,
|
|
"name": getattr(token, "name", ""),
|
|
"owner": getattr(token, "owner", None),
|
|
"token_prefix": getattr(token, "token_prefix", ""),
|
|
"scopes": scope_list,
|
|
}
|
|
_invalidate_cache(request)
|
|
return response
|
|
|
|
@router.delete("/tokens/{token_id}")
|
|
def delete_token(request: Request, token_id: str):
|
|
require_admin(request)
|
|
with get_db_session() as db:
|
|
deleted = db.query(ApiToken).filter(ApiToken.id == token_id).delete()
|
|
if not deleted:
|
|
raise HTTPException(404, "Token not found")
|
|
_invalidate_cache(request)
|
|
return {"status": "deleted"}
|
|
|
|
return router
|