diff --git a/.gitignore b/.gitignore index 91cf32e..00c9be5 100644 --- a/.gitignore +++ b/.gitignore @@ -207,4 +207,4 @@ marimo/_static/ marimo/_lsp/ __marimo__/ -.DS_Store \ No newline at end of file +.DS_Storebackups/ diff --git a/alembic/versions/2026_04_23_0001-a1b2c3d4e5f6_user_invites.py b/alembic/versions/2026_04_23_0001-a1b2c3d4e5f6_user_invites.py new file mode 100644 index 0000000..537aca1 --- /dev/null +++ b/alembic/versions/2026_04_23_0001-a1b2c3d4e5f6_user_invites.py @@ -0,0 +1,55 @@ +"""user_invites + +Revision ID: a1b2c3d4e5f6 +Revises: 4ea22876971b +Create Date: 2026-04-23 00:00:01 + +Creates the user_invites table backing invite-only /personal signup. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, Sequence[str], None] = "4ea22876971b" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "user_invites", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("token", sa.String(length=64), nullable=False), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("inviter_user_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("note", sa.String(length=255), nullable=True), + sa.Column("redeemed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "redeemed_by_user_id", postgresql.UUID(as_uuid=True), nullable=True + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("max_uses", sa.Integer(), server_default="1", nullable=False), + sa.Column("use_count", sa.Integer(), server_default="0", nullable=False), + sa.ForeignKeyConstraint(["inviter_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["redeemed_by_user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_user_invites_token"), "user_invites", ["token"], unique=True + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_user_invites_token"), table_name="user_invites") + op.drop_table("user_invites") diff --git a/src/core/auth.py b/src/core/auth.py index fed9eb7..65ac1c8 100644 --- a/src/core/auth.py +++ b/src/core/auth.py @@ -1,11 +1,14 @@ """Authentication dependencies.""" from typing import Optional +from uuid import UUID + +from fastapi import Depends, Header -from fastapi import Depends from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from src.exceptions import UnauthorizedException +from src.core.settings import settings +from src.exceptions import BadRequestException, UnauthorizedException from src.models.auth import User from src.services.auth import AuthService from src.core.security import api_key_header @@ -86,7 +89,7 @@ async def get_current_user( api_key: Optional[str] = Depends(api_key_header), ) -> User: """Get current user from either JWT token or API key.""" - + # Try JWT token first if credentials: user_id = auth_service.verify_token(credentials.credentials) @@ -94,12 +97,58 @@ async def get_current_user( user = await auth_service.get_user_by_id(user_id) if user and user.is_active: return user - + # Try API key if api_key: user = await auth_service.verify_api_key(api_key) if user and user.is_active: return user - + # Neither method worked raise UnauthorizedException("Not authenticated") + + +async def get_current_user_or_service( + auth_service: AuthService = Depends(), + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + api_key: Optional[str] = Depends(api_key_header), + x_service_key: Optional[str] = Header(default=None, alias="X-Service-Key"), + x_on_behalf_of: Optional[str] = Header(default=None, alias="X-On-Behalf-Of"), +) -> User: + """Accept user JWT / API key *or* service-key delegation. + + Service delegation: the caller presents ``X-Service-Key`` matching + ``settings.AGENTS_SERVICE_API_KEY`` and ``X-On-Behalf-Of: ``. + The returned ``User`` is the delegated-to user. Used by wisdom-agents + so each entity's network / broker / registry calls are attributed to + the entity's owner instead of a shared account. + + The service key is infra-level (env-only, not DB-backed). Treat leaks + the same way you'd treat any root credential. + """ + # Service mode — only if the service-key header is present and matches + if x_service_key is not None: + configured = settings.AGENTS_SERVICE_API_KEY + if not configured or x_service_key != configured: + raise UnauthorizedException("Invalid service key") + if not x_on_behalf_of: + raise BadRequestException( + "X-On-Behalf-Of header is required when X-Service-Key is set", + ) + try: + target_id = UUID(x_on_behalf_of) + except (ValueError, TypeError) as exc: + raise BadRequestException( + "X-On-Behalf-Of must be a valid UUID", + ) from exc + user = await auth_service.get_user_by_id(target_id) + if user is None or not user.is_active: + raise UnauthorizedException("Delegated user not found or inactive") + return user + + # Fall through to the normal user-auth flow + return await get_current_user( + auth_service=auth_service, + credentials=credentials, + api_key=api_key, + ) diff --git a/src/core/settings.py b/src/core/settings.py index 271395a..b9647af 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -107,6 +107,21 @@ class Settings(BaseSettings): SAFETY_CHECK_ENABLED: bool = True AGENT_STATUS_CACHE_TTL: int = 300 # seconds to cache agent active status in Redis + # ── Intuno Personal (hosted entity service — wisdom-agents proxy) ── + # wisdom proxies /personal/entities/* to wisdom-agents, which is a + # private internal service (never exposed to the public internet). + INTUNO_AGENTS_BASE_URL: str = "http://localhost:8001" + INTUNO_AGENTS_API_KEY: str = "" # shared secret; the same AGENTS_API_KEY from wisdom-agents + INTUNO_AGENTS_TIMEOUT_SECONDS: float = 30.0 + INTUNO_AGENTS_CHAT_TIMEOUT_SECONDS: float = 60.0 # chat waits on LLM response + PERSONAL_FREE_TIER_ENTITY_CAP: int = 1 # entities allowed on Free plan — Pro is handled separately + + # Service credential for wisdom-agents to make network/registry/broker + # calls on behalf of a specific user (the entity's owner). Not tied to + # any user account; set once per deployment. Paired with an X-On-Behalf-Of + # header containing the target user UUID. See get_current_user_or_service. + AGENTS_SERVICE_API_KEY: str = "" + # ── Economy settings (from agent-economy) ────────────────────────── ECONOMY_WELCOME_BONUS_CREDITS: int = 500 ECONOMY_CREDIT_PACKAGES: list[dict] = [ diff --git a/src/main.py b/src/main.py index 0f1fd46..dd62bc3 100644 --- a/src/main.py +++ b/src/main.py @@ -31,6 +31,8 @@ from src.routes.registry import router as registry_router from src.routes.task import router as task_router from src.routes.admin import router as admin_router +from src.routes.invite import router as invite_router +from src.routes.personal import router as personal_router from src.routes.safety import router as safety_router from src.mcp_app import create_mcp_app @@ -243,6 +245,8 @@ async def handle_workflow_exception(_request: Request, exc: WorkflowAppException # ── Admin / Safety routers ─────────────────────────────────────────── app.include_router(admin_router, tags=["Admin"]) +app.include_router(invite_router) +app.include_router(personal_router) app.include_router(safety_router, tags=["Safety"]) # ── Workflow routers (from agent-os) ───────────────────────────────── diff --git a/src/models/user_invite.py b/src/models/user_invite.py new file mode 100644 index 0000000..7402983 --- /dev/null +++ b/src/models/user_invite.py @@ -0,0 +1,51 @@ +"""User-invite model — gates signup to /personal during early access. + +Each row is a single token a prospective user can redeem to create an +account. Tokens may be single- or multi-use, optionally email-locked, +and optionally time-bounded. Operators create invites via the HTTP +API (service-key auth) or the CLI. +""" + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, func +from sqlalchemy.dialects.postgresql import UUID as PG_UUID + +from src.models.base import Base +import uuid + + +class UserInvite(Base): + """An invitation token.""" + + __tablename__ = "user_invites" + + id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # The URL-safe token string (the "shareable secret"); unique index. + token = Column(String(64), unique=True, nullable=False, index=True) + + # Optional pre-filled email. When set, redemption must use this email + # (tokens email-locked to a specific address). When null, any email + # works on redemption. + email = Column(String(255), nullable=True) + + # Who sent it. Null for admin-issued / CLI-issued tokens. + inviter_user_id = Column( + PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=True + ) + + # Internal label for the operator (e.g., "beta-round-1"). + note = Column(String(255), nullable=True) + + # Redemption state. + redeemed_at = Column(DateTime(timezone=True), nullable=True) + redeemed_by_user_id = Column( + PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=True + ) + + # Optional expiry (NULL = no expiry). + expires_at = Column(DateTime(timezone=True), nullable=True) + + # Single-use (1) or multi-use (>1). use_count is bumped on redemption. + max_uses = Column(Integer, nullable=False, server_default="1") + use_count = Column(Integer, nullable=False, server_default="0") diff --git a/src/network/a2a/routes.py b/src/network/a2a/routes.py index 7aec70a..25861f2 100644 --- a/src/network/a2a/routes.py +++ b/src/network/a2a/routes.py @@ -13,7 +13,7 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel, Field -from src.core.auth import get_current_user +from src.core.auth import get_current_user_or_service as get_current_user from src.models.auth import User from src.network.a2a.agent_card import build_agent_card, build_platform_card from src.network.a2a.protocol import ( diff --git a/src/network/routes/channels.py b/src/network/routes/channels.py index f94073a..5dffe2c 100644 --- a/src/network/routes/channels.py +++ b/src/network/routes/channels.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, Query, Request, status from pydantic import BaseModel -from src.core.auth import get_current_user +from src.core.auth import get_current_user_or_service as get_current_user from src.models.auth import User from src.network.models.schemas import ( AckResponse, diff --git a/src/network/routes/networks.py b/src/network/routes/networks.py index 0b3d705..cb7fa47 100644 --- a/src/network/routes/networks.py +++ b/src/network/routes/networks.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, Query, status -from src.core.auth import get_current_user +from src.core.auth import get_current_user_or_service as get_current_user from src.exceptions import NotFoundException from src.models.auth import User from src.network.models.schemas import ( diff --git a/src/repositories/invite.py b/src/repositories/invite.py new file mode 100644 index 0000000..04ea109 --- /dev/null +++ b/src/repositories/invite.py @@ -0,0 +1,82 @@ +"""Repository for user_invites — CRUD only, business rules live in the service.""" + +from datetime import datetime, timezone +from typing import List, Optional +from uuid import UUID + +from fastapi import Depends +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.database import get_db +from src.models.user_invite import UserInvite + + +class InviteRepository: + def __init__(self, session: AsyncSession = Depends(get_db)): + self.session = session + + async def get_by_token(self, token: str) -> Optional[UserInvite]: + result = await self.session.execute( + select(UserInvite).where(UserInvite.token == token) + ) + return result.scalar_one_or_none() + + async def get_by_id(self, invite_id: UUID) -> Optional[UserInvite]: + result = await self.session.execute( + select(UserInvite).where(UserInvite.id == invite_id) + ) + return result.scalar_one_or_none() + + async def list( + self, + *, + unredeemed_only: bool = False, + include_expired: bool = True, + limit: int = 100, + ) -> List[UserInvite]: + stmt = select(UserInvite).order_by(UserInvite.created_at.desc()).limit(limit) + if unredeemed_only: + stmt = stmt.where(UserInvite.redeemed_at.is_(None)) + if not include_expired: + now = datetime.now(tz=timezone.utc) + stmt = stmt.where( + (UserInvite.expires_at.is_(None)) | (UserInvite.expires_at > now) + ) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def create(self, invite: UserInvite) -> UserInvite: + self.session.add(invite) + await self.session.commit() + await self.session.refresh(invite) + return invite + + async def mark_redeemed( + self, invite_id: UUID, redeemed_by_user_id: UUID + ) -> Optional[UserInvite]: + """Atomic: set redeemed_at + increment use_count. Returns the row, or + None if the invite disappeared between lookup and commit.""" + now = datetime.now(tz=timezone.utc) + result = await self.session.execute( + update(UserInvite) + .where(UserInvite.id == invite_id) + .values( + redeemed_at=now, + redeemed_by_user_id=redeemed_by_user_id, + use_count=UserInvite.use_count + 1, + ) + .returning(UserInvite) + ) + row = result.scalar_one_or_none() + await self.session.commit() + return row + + async def delete(self, invite_id: UUID) -> bool: + from sqlalchemy import delete + + result = await self.session.execute( + delete(UserInvite).where(UserInvite.id == invite_id) + ) + await self.session.commit() + return (result.rowcount or 0) > 0 diff --git a/src/routes/invite.py b/src/routes/invite.py new file mode 100644 index 0000000..8c17156 --- /dev/null +++ b/src/routes/invite.py @@ -0,0 +1,148 @@ +"""Invite routes: public preview + redeem, admin CRUD. + +Mounted at ``/invites``. Public endpoints validate the token itself; +admin endpoints require ``X-Service-Key``. No route accepts a normal +user JWT — invites pre-exist accounts. +""" + +from typing import List, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.settings import settings +from src.database import get_db +from src.exceptions import UnauthorizedException +from src.schemas.invite import ( + InviteCreate, + InviteCreateResponse, + InvitePreview, + InviteRedeem, + InviteRedeemResponse, + InviteResponse, +) +from src.services.invite import ( + InviteEmailRequiredError, + InviteEmailTakenError, + InviteError, + InviteExhaustedError, + InviteExpiredError, + InviteNotFoundError, + InviteService, + _build_url, +) + +router = APIRouter(prefix="/invites", tags=["Invites"]) + + +# ─── auth helper ─────────────────────────────────────────────────── + + +async def require_service_key( + x_service_key: Optional[str] = Header(default=None, alias="X-Service-Key"), +) -> None: + """Reuse the service-key header for admin invite operations. + + We don't accept user JWTs here — inviting is an operator concern, not + a user-facing one (yet). When a future referral UI lands, it can + delegate via a separate route that does use user auth. + """ + configured = settings.AGENTS_SERVICE_API_KEY + if not configured or x_service_key != configured: + raise UnauthorizedException("Invalid service key") + + +# ─── helpers ─────────────────────────────────────────────────────── + + +def _map_invite_error_to_http(exc: InviteError) -> HTTPException: + return HTTPException(status_code=exc.status_code, detail=str(exc)) + + +# ─── public: preview + redeem ────────────────────────────────────── + + +@router.get("/{token}/preview", response_model=InvitePreview) +async def preview_invite( + token: str, + service: InviteService = Depends(), +) -> InvitePreview: + try: + invite = await service.preview(token) + except (InviteNotFoundError, InviteExpiredError, InviteExhaustedError) as exc: + raise _map_invite_error_to_http(exc) from exc + + inviter_name = await service.resolve_inviter_name(invite) + return InvitePreview( + email=invite.email, + inviter_name=inviter_name, + expires_at=invite.expires_at, + max_uses=invite.max_uses, + use_count=invite.use_count, + ) + + +@router.post( + "/{token}/redeem", + response_model=InviteRedeemResponse, + status_code=status.HTTP_201_CREATED, +) +async def redeem_invite( + token: str, + body: InviteRedeem, + service: InviteService = Depends(), +) -> InviteRedeemResponse: + try: + jwt = await service.redeem(token, body) + except InviteError as exc: + raise _map_invite_error_to_http(exc) from exc + + return InviteRedeemResponse(access_token=jwt) + + +# ─── admin: create / list / delete ───────────────────────────────── + + +@router.post( + "", + response_model=InviteCreateResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_invite( + data: InviteCreate, + _: None = Depends(require_service_key), + service: InviteService = Depends(), +) -> InviteCreateResponse: + invite, url = await service.create_invite(data) + return InviteCreateResponse( + id=invite.id, + token=invite.token, + expires_at=invite.expires_at, + url=url, + ) + + +@router.get("", response_model=List[InviteResponse]) +async def list_invites( + _: None = Depends(require_service_key), + unredeemed_only: bool = False, + include_expired: bool = True, + service: InviteService = Depends(), +) -> List[InviteResponse]: + rows = await service.list_invites( + unredeemed_only=unredeemed_only, + include_expired=include_expired, + ) + return [InviteResponse.model_validate(r) for r in rows] + + +@router.delete("/{invite_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_invite( + invite_id: UUID, + _: None = Depends(require_service_key), + service: InviteService = Depends(), +) -> None: + ok = await service.delete_invite(invite_id) + if not ok: + raise HTTPException(status_code=404, detail="Invite not found") diff --git a/src/routes/personal.py b/src/routes/personal.py new file mode 100644 index 0000000..49778b0 --- /dev/null +++ b/src/routes/personal.py @@ -0,0 +1,154 @@ +"""/personal/entities — Intuno Personal proxy routes. + +Frontend users authenticate with wisdom (JWT) and hit these routes; +wisdom forwards to the private wisdom-agents service with the shared +X-API-Key + X-User-Id header. This is the only public entry point to +Intuno Personal. + +See the [personal-proxy] ticket and wisdom-agents [personal-trust] +for the bridge design. +""" + +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Query, status +from pydantic import BaseModel, Field + +from src.core.auth import get_current_user_from_token +from src.exceptions import BadRequestException +from src.models.auth import User +from src.services.personal import PersonalAgentsClient, get_personal_client + +router = APIRouter(prefix="/personal", tags=["Personal"]) + + +# ── Local request/response models ────────────────────────────────── +# +# We keep these loose (``Any`` on the entity body) because the full +# schema lives in wisdom-agents. The proxy's job is to forward, not +# duplicate the 30-field schema here. Changes to wisdom-agents' +# EntityConfig schema flow through without needing wisdom updates. + + +class ChatSendBody(BaseModel): + content: str = Field(..., min_length=1, max_length=65536) + + +class ChatSendResponse(BaseModel): + message_id: Optional[str] = None + reply: str + + +# ── Entity CRUD ──────────────────────────────────────────────────── + + +@router.post("/entities", status_code=status.HTTP_201_CREATED) +async def create_entity( + body: dict[str, Any], + current_user: User = Depends(get_current_user_from_token), + client: PersonalAgentsClient = Depends(get_personal_client), +) -> dict[str, Any]: + """Create a new entity for the authenticated user. + + Enforces the Free-tier quota before forwarding. Body shape matches + wisdom-agents' ``EntityConfigCreate``. + """ + from src.core.settings import settings + + # Quota: count user's existing entities. + existing = await client.list_entities(current_user.id) + if len(existing) >= settings.PERSONAL_FREE_TIER_ENTITY_CAP: + raise BadRequestException( + f"Entity cap reached ({settings.PERSONAL_FREE_TIER_ENTITY_CAP}). " + "Upgrade your plan to create more.", + ) + + return await client.create_entity(current_user.id, body) + + +@router.get("/entities") +async def list_entities( + current_user: User = Depends(get_current_user_from_token), + client: PersonalAgentsClient = Depends(get_personal_client), +) -> list[dict[str, Any]]: + """List all entities owned by the authenticated user.""" + return await client.list_entities(current_user.id) + + +@router.get("/entities/{name}") +async def get_entity( + name: str, + current_user: User = Depends(get_current_user_from_token), + client: PersonalAgentsClient = Depends(get_personal_client), +) -> dict[str, Any]: + return await client.get_entity(current_user.id, name) + + +@router.patch("/entities/{name}") +async def update_entity( + name: str, + patch: dict[str, Any], + current_user: User = Depends(get_current_user_from_token), + client: PersonalAgentsClient = Depends(get_personal_client), +) -> dict[str, Any]: + if not patch: + raise BadRequestException("No fields to update") + return await client.update_entity(current_user.id, name, patch) + + +@router.delete("/entities/{name}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_entity( + name: str, + current_user: User = Depends(get_current_user_from_token), + client: PersonalAgentsClient = Depends(get_personal_client), +) -> None: + await client.delete_entity(current_user.id, name) + + +@router.post("/entities/{name}/pause") +async def pause_entity( + name: str, + current_user: User = Depends(get_current_user_from_token), + client: PersonalAgentsClient = Depends(get_personal_client), +) -> dict[str, Any]: + return await client.pause_entity(current_user.id, name) + + +@router.post("/entities/{name}/resume") +async def resume_entity( + name: str, + current_user: User = Depends(get_current_user_from_token), + client: PersonalAgentsClient = Depends(get_personal_client), +) -> dict[str, Any]: + return await client.resume_entity(current_user.id, name) + + +# ── Chat ─────────────────────────────────────────────────────────── + + +@router.post( + "/entities/{name}/messages", + response_model=ChatSendResponse, + status_code=status.HTTP_201_CREATED, +) +async def send_message( + name: str, + body: ChatSendBody, + current_user: User = Depends(get_current_user_from_token), + client: PersonalAgentsClient = Depends(get_personal_client), +) -> ChatSendResponse: + """Send a chat message to the user's entity. Blocks on the entity reply.""" + result = await client.send_chat_message(current_user.id, name, body.content) + return ChatSendResponse(**result) + + +@router.get("/entities/{name}/messages") +async def list_messages( + name: str, + limit: int = Query(default=50, ge=1, le=200), + before: Optional[str] = Query(default=None), + current_user: User = Depends(get_current_user_from_token), + client: PersonalAgentsClient = Depends(get_personal_client), +) -> list[dict[str, Any]]: + """Paginated history for the user's chat with this entity.""" + return await client.list_chat_history(current_user.id, name, limit=limit, before=before) diff --git a/src/routes/registry.py b/src/routes/registry.py index 60a9acf..728a4c5 100644 --- a/src/routes/registry.py +++ b/src/routes/registry.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status -from src.core.auth import get_current_user +from src.core.auth import get_current_user_or_service as get_current_user from src.exceptions import BadRequestException, ForbiddenException, NotFoundException from src.models.auth import User from src.schemas.registry import ( diff --git a/src/schemas/invite.py b/src/schemas/invite.py new file mode 100644 index 0000000..5583202 --- /dev/null +++ b/src/schemas/invite.py @@ -0,0 +1,73 @@ +"""Pydantic schemas for user_invites.""" + +from datetime import datetime +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +class InviteCreate(BaseModel): + """Admin creates an invite. All fields optional except when behavior matters.""" + + email: Optional[EmailStr] = None + note: Optional[str] = Field(default=None, max_length=255) + expires_at: Optional[datetime] = None + max_uses: int = Field(default=1, ge=1, le=10_000) + + +class InvitePreview(BaseModel): + """Public preview of an invite — returned by GET /invites/{token}/preview.""" + + email: Optional[EmailStr] = None + inviter_name: Optional[str] = None + expires_at: Optional[datetime] = None + max_uses: int + use_count: int + + +class InviteRedeem(BaseModel): + """Body for POST /invites/{token}/redeem. + + ``email`` may be provided when the invite is not email-locked. If the + invite has an email set, the request's email must match or be omitted. + """ + + email: Optional[EmailStr] = None + password: str = Field(..., min_length=6, max_length=128) + first_name: str = Field(..., min_length=1, max_length=64) + last_name: Optional[str] = Field(default=None, max_length=64) + + +class InviteResponse(BaseModel): + """Admin-facing invite row.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID + token: str + email: Optional[EmailStr] + inviter_user_id: Optional[UUID] + note: Optional[str] + redeemed_at: Optional[datetime] + redeemed_by_user_id: Optional[UUID] + expires_at: Optional[datetime] + max_uses: int + use_count: int + created_at: datetime + + +class InviteCreateResponse(BaseModel): + """Returned from POST /invites — includes the share-ready URL.""" + + id: UUID + token: str + expires_at: Optional[datetime] + url: str # e.g. https://intuno.net/personal/invite?token=… + + +class InviteRedeemResponse(BaseModel): + """Returned from POST /invites/{token}/redeem — logs the user in.""" + + access_token: str + token_type: str = "bearer" diff --git a/src/scripts/create_invite.py b/src/scripts/create_invite.py new file mode 100644 index 0000000..5529d2f --- /dev/null +++ b/src/scripts/create_invite.py @@ -0,0 +1,59 @@ +"""CLI to mint a user invite and print the share-ready URL. + +Usage: + python -m src.scripts.create_invite \ + --email arturo@intuno.ai \ + --note "beta-2026-04" \ + --max-uses 1 + +Writes the new row directly to the DB via the repository layer. Use +this instead of the HTTP admin endpoint for operator tasks (no need to +hold the service key in a shell env when you're already root on the box). +""" + +import argparse +import asyncio +from datetime import datetime, timezone + +from src.database import async_session_factory +from src.repositories.invite import InviteRepository +from src.models.user_invite import UserInvite +from src.services.invite import _build_url, _gen_token + + +async def _run(email: str | None, note: str | None, max_uses: int) -> None: + async with async_session_factory() as session: + repo = InviteRepository(session) + invite = UserInvite( + token=_gen_token(), + email=email, + note=note, + max_uses=max_uses, + use_count=0, + ) + saved = await repo.create(invite) + + print(f"Invite created: {saved.id}") + print(f" token: {saved.token}") + print(f" email: {saved.email or '(any)'}") + print(f" max_uses: {saved.max_uses}") + print(f" note: {saved.note or ''}") + print(f" URL: {_build_url(saved.token)}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Create a user invite.") + parser.add_argument("--email", help="Email to lock the invite to (optional)") + parser.add_argument("--note", help="Internal label (optional)") + parser.add_argument( + "--max-uses", + type=int, + default=1, + help="How many times the invite can be redeemed (default: 1)", + ) + args = parser.parse_args() + asyncio.run(_run(args.email, args.note, args.max_uses)) + + +if __name__ == "__main__": + main() diff --git a/src/services/invite.py b/src/services/invite.py new file mode 100644 index 0000000..8caa969 --- /dev/null +++ b/src/services/invite.py @@ -0,0 +1,190 @@ +"""Invite service — business logic for /invites preview + redeem + admin CRUD. + +Keeps repository calls + cross-service orchestration (user creation, +JWT issuance) in one place so the route layer stays thin. +""" + +import secrets +from datetime import datetime, timezone +from typing import Optional, Tuple +from uuid import UUID + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.settings import settings +from src.database import get_db +from src.models.user_invite import UserInvite +from src.repositories.invite import InviteRepository +from src.schemas.auth import UserRegister +from src.schemas.invite import InviteCreate, InviteRedeem +from src.services.auth import AuthService + + +class InviteError(ValueError): + """Base for redeem-flow failures that map to specific HTTP codes.""" + + status_code: int = 400 + + +class InviteNotFoundError(InviteError): + status_code = 404 + + +class InviteExpiredError(InviteError): + status_code = 410 + + +class InviteExhaustedError(InviteError): + status_code = 410 + + +class InviteEmailRequiredError(InviteError): + status_code = 400 + + +class InviteEmailTakenError(InviteError): + status_code = 409 + + +def _gen_token() -> str: + """URL-safe random token; ~43 chars.""" + return secrets.token_urlsafe(32) + + +def _build_url(token: str) -> str: + """Assemble the public invite URL using the frontend origin. + + wisdom's ``BASE_URL`` points at the API; the frontend lives elsewhere. + We fall back to the API host for now; swap to a dedicated + ``FRONTEND_BASE_URL`` once that lands in settings. + """ + base = getattr(settings, "FRONTEND_BASE_URL", None) or settings.BASE_URL + base = base.rstrip("/") + return f"{base}/personal/invite?token={token}" + + +class InviteService: + def __init__( + self, + session: AsyncSession = Depends(get_db), + invite_repo: InviteRepository = Depends(), + auth_service: AuthService = Depends(), + ): + self.session = session + self.invite_repo = invite_repo + self.auth_service = auth_service + + # ─── create / list / delete (admin) ──────────────────────────── + + async def create_invite( + self, + data: InviteCreate, + inviter_user_id: Optional[UUID] = None, + ) -> Tuple[UserInvite, str]: + """Create an invite with a fresh token. Returns (invite, share_url).""" + invite = UserInvite( + token=_gen_token(), + email=data.email, + note=data.note, + expires_at=data.expires_at, + max_uses=data.max_uses, + inviter_user_id=inviter_user_id, + use_count=0, + ) + saved = await self.invite_repo.create(invite) + return saved, _build_url(saved.token) + + async def list_invites( + self, *, unredeemed_only: bool = False, include_expired: bool = True + ): + return await self.invite_repo.list( + unredeemed_only=unredeemed_only, + include_expired=include_expired, + ) + + async def delete_invite(self, invite_id: UUID) -> bool: + return await self.invite_repo.delete(invite_id) + + # ─── preview (public) ────────────────────────────────────────── + + async def preview(self, token: str) -> UserInvite: + """Return the invite for preview, raising specific errors for UX.""" + invite = await self.invite_repo.get_by_token(token) + if invite is None: + raise InviteNotFoundError(f"Invite '{token[:8]}…' not found") + _ensure_available(invite) + return invite + + async def resolve_inviter_name(self, invite: UserInvite) -> Optional[str]: + """Fetch the inviter's display name (best-effort, may return None).""" + if invite.inviter_user_id is None: + return None + user = await self.auth_service.get_user_by_id(invite.inviter_user_id) + if user is None: + return None + if user.first_name: + return user.first_name + return user.email + + # ─── redeem (public) ─────────────────────────────────────────── + + async def redeem(self, token: str, body: InviteRedeem) -> str: + """Redeem an invite: validate → create user → mark redeemed. + + Returns a fresh JWT. Raises InviteError subclasses on any + validation failure; caller maps to HTTP status via + ``.status_code``. + """ + invite = await self.invite_repo.get_by_token(token) + if invite is None: + raise InviteNotFoundError(f"Invite '{token[:8]}…' not found") + _ensure_available(invite) + + # Email on the invite is a pre-fill hint + notification target, + # not a lock. The invite URL is the secret. Redemption uses whatever + # email the user submits; if they didn't, fall back to the invite's. + final_email: Optional[str] = body.email or invite.email + if not final_email: + raise InviteEmailRequiredError( + "Email is required to create your account." + ) + + # Create user via the existing auth flow. register_user raises + # ValueError("User with this email already exists") which we map + # to a specific 409 for the API. + try: + user = await self.auth_service.register_user( + UserRegister( + email=final_email, + password=body.password, + first_name=body.first_name, + last_name=body.last_name, + ) + ) + except ValueError as exc: + if "already exists" in str(exc).lower(): + raise InviteEmailTakenError( + "An account already exists for this email" + ) from exc + raise + + # Mark the invite redeemed — atomic update + use_count bump + updated = await self.invite_repo.mark_redeemed(invite.id, user.id) + if updated is None: + # Should never happen — row disappeared between lookup and commit. + raise InviteNotFoundError("Invite disappeared during redemption") + + return self.auth_service.create_access_token(user.id) + + +# ─── helpers ─────────────────────────────────────────────────────── + + +def _ensure_available(invite: UserInvite) -> None: + """Raise if the invite is expired or fully consumed.""" + now = datetime.now(tz=timezone.utc) + if invite.expires_at is not None and invite.expires_at < now: + raise InviteExpiredError("This invite has expired") + if invite.use_count >= invite.max_uses: + raise InviteExhaustedError("This invite has already been used") diff --git a/src/services/personal.py b/src/services/personal.py new file mode 100644 index 0000000..7b52ec9 --- /dev/null +++ b/src/services/personal.py @@ -0,0 +1,211 @@ +"""Intuno Personal service — thin HTTP proxy to wisdom-agents. + +wisdom owns user identity (JWT) and quota enforcement; wisdom-agents +owns entity state and runtime. This module is the bridge: given a +current user, it forwards to wisdom-agents with the shared API key + +``X-User-Id`` header so the other side can scope operations. + +wisdom-agents is a private internal service. The frontend never talks +to it directly — all traffic flows through these wisdom routes. +""" + +from typing import Any, Optional +from uuid import UUID + +import httpx +from fastapi import HTTPException + +from src.core.settings import settings +from src.exceptions import BadRequestException, ForbiddenException, NotFoundException + + +class AgentsUpstreamError(HTTPException): + """Raised when wisdom-agents returns 5xx / 401-from-bad-config / is unreachable. + + Inherits from ``HTTPException`` so FastAPI surfaces it as an HTTP + response instead of a 500 with a raw traceback. + """ + + def __init__(self, message: str, status_code: int = 502) -> None: + super().__init__(status_code=status_code, detail=message) + + +class PersonalAgentsClient: + """Typed wrapper over the wisdom-agents HTTP API. + + One instance per request via a FastAPI dependency. The underlying + ``httpx.AsyncClient`` is created and closed with the request scope + to keep connection hygiene simple. For high-QPS endpoints we'd + hoist to a shared pool; the Personal surface is low-traffic. + """ + + def __init__( + self, + base_url: Optional[str] = None, + api_key: Optional[str] = None, + timeout: Optional[float] = None, + ) -> None: + self._base_url = (base_url or settings.INTUNO_AGENTS_BASE_URL).rstrip("/") + self._api_key = api_key or settings.INTUNO_AGENTS_API_KEY + self._timeout = timeout if timeout is not None else settings.INTUNO_AGENTS_TIMEOUT_SECONDS + + def _headers(self, user_id: UUID, extra: Optional[dict] = None) -> dict: + headers = { + "X-API-Key": self._api_key, + "X-User-Id": str(user_id), + "Content-Type": "application/json", + } + if extra: + headers.update(extra) + return headers + + # ─────────────── low-level request wrapper ────────────────── + + async def _request( + self, + method: str, + path: str, + user_id: UUID, + *, + json: Optional[Any] = None, + params: Optional[dict] = None, + timeout: Optional[float] = None, + ) -> httpx.Response: + """Issue one HTTP call. Raises ``AgentsUpstreamError`` on 5xx / network fail.""" + url = f"{self._base_url}{path}" + timeout_s = timeout if timeout is not None else self._timeout + try: + async with httpx.AsyncClient(timeout=timeout_s) as client: + response = await client.request( + method, + url, + headers=self._headers(user_id), + json=json, + params=params, + ) + except httpx.HTTPError as exc: + raise AgentsUpstreamError( + f"wisdom-agents unreachable: {exc}", status_code=502 + ) from exc + + # 401 from wisdom-agents means our shared API key is wrong — a server + # misconfig, not a user-facing auth problem. Surface as 502 with a + # clean message so the traceback doesn't leak. + if response.status_code == 401: + raise AgentsUpstreamError( + "wisdom-agents rejected the shared API key — check " + "INTUNO_AGENTS_API_KEY matches AGENTS_API_KEY on wisdom-agents.", + status_code=502, + ) + + if response.status_code >= 500: + raise AgentsUpstreamError( + f"wisdom-agents {response.status_code}: {response.text[:200]}", + status_code=response.status_code, + ) + + return response + + # ─────────────── entity CRUD ──────────────────────────────── + + async def list_entities(self, user_id: UUID) -> list[dict]: + resp = await self._request("GET", "/entities", user_id) + if resp.status_code == 403: + raise ForbiddenException("forbidden") + resp.raise_for_status() + return resp.json() + + async def create_entity(self, user_id: UUID, payload: dict) -> dict: + resp = await self._request("POST", "/entities", user_id, json=payload) + if resp.status_code == 409: + raise BadRequestException(resp.json().get("detail", "Entity name taken")) + if resp.status_code in (400, 422): + raise BadRequestException(resp.json().get("detail", "Validation failed")) + resp.raise_for_status() + return resp.json() + + async def get_entity(self, user_id: UUID, name: str) -> dict: + resp = await self._request("GET", f"/entities/{name}", user_id) + if resp.status_code == 404: + raise NotFoundException(f"Entity '{name}' not found") + if resp.status_code == 403: + raise ForbiddenException("Not your entity") + resp.raise_for_status() + return resp.json() + + async def update_entity(self, user_id: UUID, name: str, patch: dict) -> dict: + resp = await self._request("PATCH", f"/entities/{name}", user_id, json=patch) + if resp.status_code == 404: + raise NotFoundException(f"Entity '{name}' not found") + if resp.status_code in (400, 422): + raise BadRequestException(resp.json().get("detail", "Update rejected")) + resp.raise_for_status() + return resp.json() + + async def delete_entity(self, user_id: UUID, name: str) -> None: + resp = await self._request("DELETE", f"/entities/{name}", user_id) + if resp.status_code == 404: + raise NotFoundException(f"Entity '{name}' not found") + if resp.status_code == 403: + raise ForbiddenException("Not your entity") + resp.raise_for_status() + + async def pause_entity(self, user_id: UUID, name: str) -> dict: + resp = await self._request("POST", f"/entities/{name}/pause", user_id) + if resp.status_code == 404: + raise NotFoundException(f"Entity '{name}' not found") + if resp.status_code == 403: + raise ForbiddenException("Not your entity") + resp.raise_for_status() + return resp.json() + + async def resume_entity(self, user_id: UUID, name: str) -> dict: + resp = await self._request("POST", f"/entities/{name}/resume", user_id) + if resp.status_code == 404: + raise NotFoundException(f"Entity '{name}' not found") + if resp.status_code == 403: + raise ForbiddenException("Not your entity") + resp.raise_for_status() + return resp.json() + + # ─────────────── chat ─────────────────────────────────────── + + async def send_chat_message(self, user_id: UUID, name: str, content: str) -> dict: + resp = await self._request( + "POST", + f"/entities/{name}/chat", + user_id, + json={"content": content}, + timeout=settings.INTUNO_AGENTS_CHAT_TIMEOUT_SECONDS, + ) + if resp.status_code == 404: + raise NotFoundException(f"Entity '{name}' not found") + if resp.status_code == 403: + raise ForbiddenException("Not your entity") + resp.raise_for_status() + return resp.json() + + async def list_chat_history( + self, + user_id: UUID, + name: str, + limit: int = 50, + before: Optional[str] = None, + ) -> list[dict]: + params: dict = {"limit": limit} + if before: + params["before"] = before + resp = await self._request( + "GET", f"/entities/{name}/chat", user_id, params=params + ) + if resp.status_code == 404: + raise NotFoundException(f"Entity '{name}' not found") + if resp.status_code == 403: + raise ForbiddenException("Not your entity") + resp.raise_for_status() + return resp.json() + + +def get_personal_client() -> PersonalAgentsClient: + """FastAPI dependency factory.""" + return PersonalAgentsClient()