diff --git a/backend/app/api/bot.py b/backend/app/api/bot.py new file mode 100644 index 0000000..89ec1d0 --- /dev/null +++ b/backend/app/api/bot.py @@ -0,0 +1,173 @@ +"""Bot API key management and project report endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from sqlmodel import col, desc, select + +from app.api.deps import require_org_admin, require_org_member +from app.core.agent_tokens import generate_agent_token, hash_agent_token, verify_agent_token +from app.core.time import utcnow +from app.db.session import get_session +from app.models.bot import BotApiKey, BotReport +from app.schemas.bot import ( + BotApiKeyCreate, + BotApiKeyCreated, + BotApiKeyRead, + BotReportCreate, + BotReportRead, +) +from app.services.organizations import OrganizationContext + +if TYPE_CHECKING: + from sqlmodel.ext.asyncio.session import AsyncSession + +router = APIRouter(prefix="/bot", tags=["bot"]) + +SESSION_DEP = Depends(get_session) +ORG_MEMBER_DEP = Depends(require_org_member) +ORG_ADMIN_DEP = Depends(require_org_admin) + + +async def _get_bot_key_auth( + x_bot_key: str | None = Header(default=None, alias="X-Bot-Key"), + session: AsyncSession = SESSION_DEP, +) -> BotApiKey: + """Validate X-Bot-Key header against stored hashes. Returns the matching key row.""" + if not x_bot_key: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="X-Bot-Key header required.") + keys = list(await session.exec(select(BotApiKey))) + for key in keys: + if verify_agent_token(x_bot_key, key.key_hash): + key.last_used_at = utcnow() + session.add(key) + await session.commit() + return key + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid bot key.") + + +BOT_KEY_DEP = Depends(_get_bot_key_auth) + + +# --------------------------------------------------------------------------- +# Key management (user auth) +# --------------------------------------------------------------------------- + +@router.post("/keys", response_model=BotApiKeyCreated, status_code=status.HTTP_201_CREATED) +async def create_bot_key( + data: BotApiKeyCreate, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> BotApiKeyCreated: + """Generate a new bot API key. The plaintext key is returned once — store it immediately.""" + plaintext = generate_agent_token() + key = BotApiKey( + organization_id=ctx.organization.id, + name=data.name, + key_hash=hash_agent_token(plaintext), + key_last_four=plaintext[-4:], + ) + session.add(key) + await session.commit() + await session.refresh(key) + return BotApiKeyCreated( + id=key.id, + name=key.name, + key_last_four=key.key_last_four, + created_at=key.created_at, + last_used_at=key.last_used_at, + key=plaintext, + ) + + +@router.get("/keys", response_model=list[BotApiKeyRead]) +async def list_bot_keys( + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> list[BotApiKeyRead]: + """List all bot API keys for this organization.""" + keys = list(await session.exec( + select(BotApiKey) + .where(col(BotApiKey.organization_id) == ctx.organization.id) + .order_by(desc(col(BotApiKey.created_at))) + )) + return [BotApiKeyRead.model_validate(k, from_attributes=True) for k in keys] + + +@router.delete("/keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_bot_key( + key_id: UUID, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_ADMIN_DEP, +) -> None: + """Revoke a bot API key.""" + key = await session.get(BotApiKey, key_id) + if key is None or key.organization_id != ctx.organization.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + await session.delete(key) + await session.commit() + + +# --------------------------------------------------------------------------- +# Reporting (bot key auth) +# --------------------------------------------------------------------------- + +@router.post("/report", response_model=BotReportRead, status_code=status.HTTP_201_CREATED) +async def create_bot_report( + data: BotReportCreate, + session: AsyncSession = SESSION_DEP, + key: BotApiKey = BOT_KEY_DEP, +) -> BotReportRead: + """Record what the bot is currently working on.""" + report = BotReport( + organization_id=key.organization_id, + key_id=key.id, + project=data.project, + task=data.task, + status=data.status, + detail=data.detail, + ) + session.add(report) + await session.commit() + await session.refresh(report) + return BotReportRead.model_validate(report, from_attributes=True) + + +# --------------------------------------------------------------------------- +# Report reading (user auth) +# --------------------------------------------------------------------------- + +@router.get("/reports/latest", response_model=BotReportRead) +async def get_latest_bot_report( + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> BotReportRead: + """Return the most recent bot report for this organization.""" + report = (await session.exec( + select(BotReport) + .where(col(BotReport.organization_id) == ctx.organization.id) + .order_by(desc(col(BotReport.reported_at))) + .limit(1) + )).first() + if report is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return BotReportRead.model_validate(report, from_attributes=True) + + +@router.get("/reports", response_model=list[BotReportRead]) +async def list_bot_reports( + limit: int = Query(default=50, ge=1, le=200), + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> list[BotReportRead]: + """List bot reports newest-first.""" + reports = list(await session.exec( + select(BotReport) + .where(col(BotReport.organization_id) == ctx.organization.id) + .order_by(desc(col(BotReport.reported_at))) + .limit(limit) + )) + return [BotReportRead.model_validate(r, from_attributes=True) for r in reports] diff --git a/backend/app/main.py b/backend/app/main.py index 7de42a6..88c38f1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from fastapi.openapi.utils import get_openapi from fastapi_pagination import add_pagination from app.api.activity import router as activity_router +from app.api.bot import router as bot_router from app.api.agent import router as agent_router from app.api.agent_forgejo import router as agent_forgejo_router from app.api.agent_sessions import router as agent_sessions_router @@ -607,6 +608,7 @@ def readyz() -> HealthStatusResponse: api_v1 = APIRouter(prefix="/api/v1") api_v1.include_router(auth_router) +api_v1.include_router(bot_router) api_v1.include_router(agent_router) api_v1.include_router(agents_router) api_v1.include_router(activity_router) diff --git a/backend/app/models/bot.py b/backend/app/models/bot.py new file mode 100644 index 0000000..a60171a --- /dev/null +++ b/backend/app/models/bot.py @@ -0,0 +1,38 @@ +"""Models for bot API keys and project reports.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import JSON, Column +from sqlmodel import Field + +from app.core.time import utcnow +from app.models.base import QueryModel + + +class BotApiKey(QueryModel, table=True): + __tablename__ = "bot_api_keys" # pyright: ignore[reportAssignmentType] + + id: UUID = Field(default_factory=uuid4, primary_key=True) + organization_id: UUID = Field(foreign_key="organizations.id", index=True) + name: str = Field(index=True) + key_hash: str = Field(index=True) + key_last_four: str + created_at: datetime = Field(default_factory=utcnow) + last_used_at: datetime | None = Field(default=None) + + +class BotReport(QueryModel, table=True): + __tablename__ = "bot_reports" # pyright: ignore[reportAssignmentType] + + id: UUID = Field(default_factory=uuid4, primary_key=True) + organization_id: UUID = Field(foreign_key="organizations.id", index=True) + key_id: UUID = Field(foreign_key="bot_api_keys.id", index=True) + project: str | None = Field(default=None) + task: str | None = Field(default=None) + status: str | None = Field(default=None) + detail: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + reported_at: datetime = Field(default_factory=utcnow, index=True) diff --git a/backend/app/schemas/bot.py b/backend/app/schemas/bot.py new file mode 100644 index 0000000..d6d917d --- /dev/null +++ b/backend/app/schemas/bot.py @@ -0,0 +1,43 @@ +"""Schemas for bot API keys and project reports.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from sqlmodel import Field, SQLModel + + +class BotApiKeyCreate(SQLModel): + name: str + + +class BotApiKeyRead(SQLModel): + id: UUID + name: str + key_last_four: str + created_at: datetime + last_used_at: datetime | None + + +class BotApiKeyCreated(BotApiKeyRead): + """Returned once on creation — includes the plaintext key, never stored.""" + key: str + + +class BotReportCreate(SQLModel): + project: str | None = None + task: str | None = None + status: str | None = "busy" + detail: dict[str, Any] | None = None + + +class BotReportRead(SQLModel): + id: UUID + key_id: UUID + project: str | None + task: str | None + status: str | None + detail: dict[str, Any] | None + reported_at: datetime diff --git a/backend/migrations/versions/d5e6f7a8b9c0_add_bot_api_keys_and_reports.py b/backend/migrations/versions/d5e6f7a8b9c0_add_bot_api_keys_and_reports.py new file mode 100644 index 0000000..2b84f4b --- /dev/null +++ b/backend/migrations/versions/d5e6f7a8b9c0_add_bot_api_keys_and_reports.py @@ -0,0 +1,63 @@ +"""add bot api keys and reports + +Revision ID: d5e6f7a8b9c0 +Revises: c6d7e8f9a0b1 +Create Date: 2026-05-26 00:00:00.000000 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "d5e6f7a8b9c0" +down_revision = "c6d7e8f9a0b1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + inspector = inspect(op.get_bind()) + + if not inspector.has_table("bot_api_keys"): + op.create_table( + "bot_api_keys", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("key_hash", sa.String(), nullable=False), + sa.Column("key_last_four", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("last_used_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_bot_api_keys_organization_id", "bot_api_keys", ["organization_id"]) + op.create_index("ix_bot_api_keys_key_hash", "bot_api_keys", ["key_hash"]) + op.create_index("ix_bot_api_keys_name", "bot_api_keys", ["name"]) + + if not inspector.has_table("bot_reports"): + op.create_table( + "bot_reports", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("key_id", sa.Uuid(), nullable=False), + sa.Column("project", sa.String(), nullable=True), + sa.Column("task", sa.String(), nullable=True), + sa.Column("status", sa.String(), nullable=True), + sa.Column("detail", sa.JSON(), nullable=True), + sa.Column("reported_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]), + sa.ForeignKeyConstraint(["key_id"], ["bot_api_keys.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_bot_reports_organization_id", "bot_reports", ["organization_id"]) + op.create_index("ix_bot_reports_key_id", "bot_reports", ["key_id"]) + op.create_index("ix_bot_reports_reported_at", "bot_reports", ["reported_at"]) + + +def downgrade() -> None: + op.drop_table("bot_reports") + op.drop_table("bot_api_keys") diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index e3f254b..8d00e12 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -63,6 +63,7 @@ import { } from "@/components/dashboard/RuntimeUsageSection"; import { GatewayHealthPanel } from "@/components/dashboard/GatewayHealthPanel"; import { GatewayCronPanel } from "@/components/dashboard/GatewayCronPanel"; +import { BotStatusSection } from "@/components/dashboard/BotStatusSection"; import { type listAgentsApiV1AgentsGetResponse, useListAgentsApiV1AgentsGet, @@ -1462,6 +1463,10 @@ export default function DashboardPage() { )} +
{apiKey.name}
++ ending ••••{apiKey.key_last_four} · created {createdAt} · last used {lastUsed} +
++ Give the key a name (e.g. "Ripley"), then click Generate. The plaintext key + is shown once — copy it immediately. +
+Failed to generate key. Try again.
+ )} +No keys yet. Generate one above.
+ )} + {keys.map((k) => ( ++ {report.project ?? "—"} +
+ {report.task && ( +{report.task}
+ )} +