Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -207,4 +207,4 @@ marimo/_static/
marimo/_lsp/
__marimo__/

.DS_Store
.DS_Storebackups/
55 changes: 55 additions & 0 deletions alembic/versions/2026_04_23_0001-a1b2c3d4e5f6_user_invites.py
Original file line number Diff line number Diff line change
@@ -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")
59 changes: 54 additions & 5 deletions src/core/auth.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -86,20 +89,66 @@ 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)
if user_id:
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: <user_uuid>``.
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,
)
15 changes: 15 additions & 0 deletions src/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down
4 changes: 4 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) ─────────────────────────────────
Expand Down
51 changes: 51 additions & 0 deletions src/models/user_invite.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 1 addition & 1 deletion src/network/a2a/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion src/network/routes/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/network/routes/networks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
82 changes: 82 additions & 0 deletions src/repositories/invite.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading