feat: Add presence heartbeat for Matrix online status

- matrix-gateway: POST /internal/matrix/presence/online endpoint
- usePresenceHeartbeat hook with activity tracking
- Auto away after 5 min inactivity
- Offline on page close/visibility change
- Integrated in MatrixChatRoom component
This commit is contained in:
Apple
2025-11-27 00:19:40 -08:00
parent 5bed515852
commit 3de3c8cb36
6371 changed files with 1317450 additions and 932 deletions

View File

@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 7015
CMD ["python", "main.py"]

View File

@@ -0,0 +1,172 @@
"""
DAARION MicroDAO Service — Phase 7
Port: 7015
MicroDAO Console — Backend Complete
"""
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import asyncpg
# Import routes
import routes_microdao
import routes_members
import routes_treasury
import routes_settings
# Import repository and NATS
from repository_microdao import MicrodaoRepository
from nats_client import NATSPublisher
# ============================================================================
# Configuration
# ============================================================================
PORT = int(os.getenv("PORT", "7015"))
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/daarion")
NATS_URL = os.getenv("NATS_URL", "nats://localhost:4222")
# ============================================================================
# Lifespan — Startup & Shutdown
# ============================================================================
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Startup:
- Connect to PostgreSQL
- Initialize repository
- Connect to NATS
- Set up routes with dependencies
Shutdown:
- Close DB connection
- Close NATS connection
"""
print("🚀 MicroDAO Service starting...")
# Connect to PostgreSQL
try:
db_pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)
print(f"✅ PostgreSQL connected")
except Exception as e:
print(f"❌ Failed to connect to PostgreSQL: {e}")
raise
app.state.db_pool = db_pool
# Initialize repository
repo = MicrodaoRepository(db_pool)
app.state.repo = repo
# Set repository for all routes
routes_microdao.set_repository(repo)
routes_members.set_repository(repo)
routes_treasury.set_repository(repo)
routes_settings.set_repository(repo)
# Connect to NATS
nats_pub = NATSPublisher(NATS_URL)
try:
await nats_pub.connect()
app.state.nats_pub = nats_pub
# Set NATS publisher for all routes
async def publish_wrapper(subject: str, payload: dict):
await nats_pub.publish(subject, payload)
routes_microdao.set_nats_publisher(publish_wrapper)
routes_members.set_nats_publisher(publish_wrapper)
routes_treasury.set_nats_publisher(publish_wrapper)
routes_settings.set_nats_publisher(publish_wrapper)
print("✅ NATS publisher configured")
except Exception as e:
print(f"⚠️ NATS connection failed: {e}")
print("⚠️ Service will run without NATS events")
app.state.nats_pub = None
print(f"🎉 MicroDAO Service ready on port {PORT}")
yield
# Shutdown
if hasattr(app.state, 'nats_pub') and app.state.nats_pub:
await app.state.nats_pub.close()
await db_pool.close()
print("✅ MicroDAO Service stopped")
app = FastAPI(
title="DAARION MicroDAO Service",
description="MicroDAO Console (MVP)",
version="1.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(routes_microdao.router)
app.include_router(routes_members.router)
app.include_router(routes_treasury.router)
app.include_router(routes_settings.router)
@app.get("/health")
async def health():
return {
"service": "microdao-service",
"version": "1.0.0",
"status": "healthy",
"phase": "7"
}
@app.get("/")
async def root():
return {
"service": "DAARION MicroDAO Service",
"version": "1.0.0",
"phase": "7",
"endpoints": {
"health": "/health",
"microdaos": "/microdao",
"members": "/microdao/{slug}/members",
"treasury": "/microdao/{slug}/treasury"
}
}
if __name__ == "__main__":
import uvicorn
print(f"""
╔══════════════════════════════════════════════════════════════╗
║ ║
║ 🏛️ DAARION MICRODAO SERVICE — PHASE 7 🏛️ ║
║ ║
║ Port: {PORT:<50}
║ Database: PostgreSQL ║
║ ║
║ Features: ║
║ ✅ MicroDAO CRUD ║
║ ✅ Member management ║
║ ✅ Treasury tracking ║
║ ✅ Settings & roles ║
║ ║
╚══════════════════════════════════════════════════════════════╝
""")
uvicorn.run(
"main:app",
host="0.0.0.0",
port=PORT,
reload=False,
log_level="info"
)

View File

@@ -0,0 +1,83 @@
"""
MicroDAO Service Models
Phase 7: microDAO Console (MVP)
"""
from pydantic import BaseModel, Field
from typing import Optional, Any
from datetime import datetime
from decimal import Decimal
# ============================================================================
# MicroDAO Models
# ============================================================================
class MicrodaoBase(BaseModel):
slug: str = Field(..., min_length=3, max_length=50)
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
class MicrodaoCreate(MicrodaoBase):
pass
class MicrodaoUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
is_active: Optional[bool] = None
class MicrodaoRead(MicrodaoBase):
id: str
external_id: str
owner_user_id: str
is_active: bool
created_at: datetime
updated_at: datetime
member_count: int = 0
agent_count: int = 0
# ============================================================================
# Member Models
# ============================================================================
class MemberRole(str):
OWNER = "owner"
ADMIN = "admin"
MEMBER = "member"
GUEST = "guest"
class MicrodaoMember(BaseModel):
id: str
microdao_id: str
user_id: str
role: str
joined_at: datetime
class MemberAdd(BaseModel):
user_id: str
role: str = "member"
class MemberUpdateRole(BaseModel):
role: str
# ============================================================================
# Treasury Models
# ============================================================================
class TreasuryItem(BaseModel):
token_symbol: str
balance: Decimal
class TreasuryUpdate(BaseModel):
token_symbol: str
balance: Decimal
# ============================================================================
# Settings Models
# ============================================================================
class SettingItem(BaseModel):
key: str
value: dict | str | int | float | bool
class SettingsUpdate(BaseModel):
settings: dict[str, Any]

View File

@@ -0,0 +1,44 @@
"""
NATS Client — Publish microDAO events
Phase 7: Backend Completion
"""
import json
from typing import Optional, Dict, Any
from nats.aio.client import Client as NATS
class NATSPublisher:
def __init__(self, nats_url: str):
self.nats_url = nats_url
self.nc: Optional[NATS] = None
async def connect(self):
"""Connect to NATS"""
self.nc = NATS()
await self.nc.connect(self.nats_url)
print(f"✅ NATS connected: {self.nats_url}")
async def publish(self, subject: str, payload: Dict[str, Any]):
"""
Publish event to NATS
Args:
subject: NATS subject (e.g., "microdao.event.created")
payload: Event payload as dict
"""
if not self.nc:
print(f"⚠️ NATS not connected, skipping publish to {subject}")
return
try:
message = json.dumps(payload).encode()
await self.nc.publish(subject, message)
print(f"📤 Published to {subject}: {payload.get('microdao_id', 'unknown')}")
except Exception as e:
print(f"❌ Failed to publish to {subject}: {e}")
async def close(self):
"""Close NATS connection"""
if self.nc:
await self.nc.close()
print("✅ NATS connection closed")

View File

@@ -0,0 +1,463 @@
"""
MicroDAO Repository — Database operations
Phase 7: Backend Completion
"""
import uuid
from typing import List, Optional, Dict, Any
from datetime import datetime
from decimal import Decimal
import asyncpg
from models import (
MicrodaoCreate, MicrodaoUpdate, MicrodaoRead,
MicrodaoMember, MemberAdd,
TreasuryItem, TreasuryUpdate,
SettingItem
)
class MicrodaoRepository:
def __init__(self, db_pool: asyncpg.Pool):
self.db = db_pool
# ========================================================================
# MicroDAO — CRUD
# ========================================================================
async def create_microdao(
self,
data: MicrodaoCreate,
owner_user_id: str
) -> MicrodaoRead:
"""Create new microDAO and add owner as member"""
microdao_id = uuid.uuid4()
external_id = f"microdao:{microdao_id.hex[:8]}"
async with self.db.acquire() as conn:
async with conn.transaction():
# Insert microDAO
await conn.execute(
"""
INSERT INTO microdaos (id, external_id, slug, name, description, owner_user_id)
VALUES ($1, $2, $3, $4, $5, $6)
""",
microdao_id,
external_id,
data.slug,
data.name,
data.description,
uuid.UUID(owner_user_id)
)
# Add owner as member
await conn.execute(
"""
INSERT INTO microdao_members (microdao_id, user_id, role)
VALUES ($1, $2, $3)
""",
microdao_id,
uuid.UUID(owner_user_id),
"owner"
)
# Get created microDAO
return await self._get_microdao_by_id(conn, microdao_id)
async def update_microdao(
self,
microdao_id: str,
data: MicrodaoUpdate
) -> Optional[MicrodaoRead]:
"""Update microDAO"""
# Build update query dynamically
updates = []
values = []
param_idx = 1
if data.name is not None:
updates.append(f"name = ${param_idx}")
values.append(data.name)
param_idx += 1
if data.description is not None:
updates.append(f"description = ${param_idx}")
values.append(data.description)
param_idx += 1
if data.is_active is not None:
updates.append(f"is_active = ${param_idx}")
values.append(data.is_active)
param_idx += 1
if not updates:
return await self.get_microdao_by_id(microdao_id)
updates.append(f"updated_at = NOW()")
values.append(uuid.UUID(microdao_id))
query = f"""
UPDATE microdaos
SET {', '.join(updates)}
WHERE id = ${param_idx}
RETURNING id
"""
async with self.db.acquire() as conn:
row = await conn.fetchrow(query, *values)
if not row:
return None
return await self._get_microdao_by_id(conn, row['id'])
async def delete_microdao(self, microdao_id: str) -> bool:
"""Soft delete microDAO (set is_active = false)"""
result = await self.db.execute(
"""
UPDATE microdaos
SET is_active = false, updated_at = NOW()
WHERE id = $1
""",
uuid.UUID(microdao_id)
)
return result == "UPDATE 1"
async def get_microdao_by_slug(self, slug: str) -> Optional[MicrodaoRead]:
"""Get microDAO by slug"""
async with self.db.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT m.id, m.external_id, m.slug, m.name, m.description,
m.owner_user_id, m.is_active, m.created_at, m.updated_at,
COUNT(DISTINCT mm.id) as member_count,
COUNT(DISTINCT a.id) as agent_count
FROM microdaos m
LEFT JOIN microdao_members mm ON mm.microdao_id = m.id
LEFT JOIN agents a ON a.microdao_id = m.id AND a.is_active = true
WHERE m.slug = $1
GROUP BY m.id
""",
slug
)
if not row:
return None
return self._row_to_microdao(row)
async def get_microdao_by_id(self, microdao_id: str) -> Optional[MicrodaoRead]:
"""Get microDAO by ID"""
async with self.db.acquire() as conn:
return await self._get_microdao_by_id(conn, uuid.UUID(microdao_id))
async def _get_microdao_by_id(
self,
conn: asyncpg.Connection,
microdao_id: uuid.UUID
) -> Optional[MicrodaoRead]:
"""Internal: get microDAO by ID within transaction"""
row = await conn.fetchrow(
"""
SELECT m.id, m.external_id, m.slug, m.name, m.description,
m.owner_user_id, m.is_active, m.created_at, m.updated_at,
COUNT(DISTINCT mm.id) as member_count,
COUNT(DISTINCT a.id) as agent_count
FROM microdaos m
LEFT JOIN microdao_members mm ON mm.microdao_id = m.id
LEFT JOIN agents a ON a.microdao_id = m.id AND a.is_active = true
WHERE m.id = $1
GROUP BY m.id
""",
microdao_id
)
if not row:
return None
return self._row_to_microdao(row)
async def list_microdaos(self) -> List[MicrodaoRead]:
"""List all active microDAOs"""
rows = await self.db.fetch(
"""
SELECT m.id, m.external_id, m.slug, m.name, m.description,
m.owner_user_id, m.is_active, m.created_at, m.updated_at,
COUNT(DISTINCT mm.id) as member_count,
COUNT(DISTINCT a.id) as agent_count
FROM microdaos m
LEFT JOIN microdao_members mm ON mm.microdao_id = m.id
LEFT JOIN agents a ON a.microdao_id = m.id AND a.is_active = true
WHERE m.is_active = true
GROUP BY m.id
ORDER BY m.created_at DESC
"""
)
return [self._row_to_microdao(row) for row in rows]
async def list_microdaos_for_user(self, user_id: str) -> List[MicrodaoRead]:
"""List microDAOs where user is a member"""
rows = await self.db.fetch(
"""
SELECT m.id, m.external_id, m.slug, m.name, m.description,
m.owner_user_id, m.is_active, m.created_at, m.updated_at,
COUNT(DISTINCT mm.id) as member_count,
COUNT(DISTINCT a.id) as agent_count
FROM microdaos m
INNER JOIN microdao_members mm ON mm.microdao_id = m.id
LEFT JOIN agents a ON a.microdao_id = m.id AND a.is_active = true
WHERE mm.user_id = $1 AND m.is_active = true
GROUP BY m.id
ORDER BY m.created_at DESC
""",
uuid.UUID(user_id)
)
return [self._row_to_microdao(row) for row in rows]
def _row_to_microdao(self, row: asyncpg.Record) -> MicrodaoRead:
"""Convert database row to MicrodaoRead"""
return MicrodaoRead(
id=str(row['id']),
external_id=row['external_id'],
slug=row['slug'],
name=row['name'],
description=row['description'],
owner_user_id=str(row['owner_user_id']),
is_active=row['is_active'],
created_at=row['created_at'],
updated_at=row['updated_at'],
member_count=row['member_count'] or 0,
agent_count=row['agent_count'] or 0
)
# ========================================================================
# Members
# ========================================================================
async def list_members(self, microdao_id: str) -> List[MicrodaoMember]:
"""List all members of a microDAO"""
rows = await self.db.fetch(
"""
SELECT id, microdao_id, user_id, role, joined_at
FROM microdao_members
WHERE microdao_id = $1
ORDER BY joined_at ASC
""",
uuid.UUID(microdao_id)
)
return [
MicrodaoMember(
id=str(row['id']),
microdao_id=str(row['microdao_id']),
user_id=str(row['user_id']),
role=row['role'],
joined_at=row['joined_at']
)
for row in rows
]
async def add_member(
self,
microdao_id: str,
user_id: str,
role: str = "member"
) -> MicrodaoMember:
"""Add member to microDAO"""
member_id = uuid.uuid4()
row = await self.db.fetchrow(
"""
INSERT INTO microdao_members (id, microdao_id, user_id, role)
VALUES ($1, $2, $3, $4)
ON CONFLICT (microdao_id, user_id) DO UPDATE
SET role = EXCLUDED.role
RETURNING id, microdao_id, user_id, role, joined_at
""",
member_id,
uuid.UUID(microdao_id),
uuid.UUID(user_id),
role
)
return MicrodaoMember(
id=str(row['id']),
microdao_id=str(row['microdao_id']),
user_id=str(row['user_id']),
role=row['role'],
joined_at=row['joined_at']
)
async def remove_member(self, member_id: str) -> bool:
"""Remove member from microDAO"""
result = await self.db.execute(
"""
DELETE FROM microdao_members
WHERE id = $1
""",
uuid.UUID(member_id)
)
return result == "DELETE 1"
async def get_user_role_in_microdao(
self,
microdao_id: str,
user_id: str
) -> Optional[str]:
"""Get user's role in microDAO (or None if not a member)"""
row = await self.db.fetchrow(
"""
SELECT role FROM microdao_members
WHERE microdao_id = $1 AND user_id = $2
""",
uuid.UUID(microdao_id),
uuid.UUID(user_id)
)
return row['role'] if row else None
# ========================================================================
# Treasury
# ========================================================================
async def get_treasury_items(self, microdao_id: str) -> List[TreasuryItem]:
"""Get all treasury items for a microDAO"""
rows = await self.db.fetch(
"""
SELECT token_symbol, balance
FROM microdao_treasury
WHERE microdao_id = $1
ORDER BY token_symbol
""",
uuid.UUID(microdao_id)
)
return [
TreasuryItem(
token_symbol=row['token_symbol'],
balance=row['balance']
)
for row in rows
]
async def apply_treasury_delta(
self,
microdao_id: str,
token_symbol: str,
delta: Decimal
) -> TreasuryItem:
"""
Apply delta to treasury balance
Creates entry if doesn't exist
Raises error if resulting balance would be negative
"""
async with self.db.acquire() as conn:
async with conn.transaction():
# Get current balance
row = await conn.fetchrow(
"""
SELECT balance FROM microdao_treasury
WHERE microdao_id = $1 AND token_symbol = $2
FOR UPDATE
""",
uuid.UUID(microdao_id),
token_symbol
)
current_balance = row['balance'] if row else Decimal('0')
new_balance = current_balance + delta
if new_balance < 0:
raise ValueError(f"Insufficient balance: {current_balance} + {delta} = {new_balance} < 0")
# Upsert
row = await conn.fetchrow(
"""
INSERT INTO microdao_treasury (microdao_id, token_symbol, balance, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (microdao_id, token_symbol) DO UPDATE
SET balance = $3, updated_at = NOW()
RETURNING token_symbol, balance
""",
uuid.UUID(microdao_id),
token_symbol,
new_balance
)
return TreasuryItem(
token_symbol=row['token_symbol'],
balance=row['balance']
)
async def set_treasury_balance(
self,
microdao_id: str,
token_symbol: str,
balance: Decimal
) -> TreasuryItem:
"""Set treasury balance directly (for admin operations)"""
if balance < 0:
raise ValueError(f"Balance cannot be negative: {balance}")
row = await self.db.fetchrow(
"""
INSERT INTO microdao_treasury (microdao_id, token_symbol, balance, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (microdao_id, token_symbol) DO UPDATE
SET balance = $3, updated_at = NOW()
RETURNING token_symbol, balance
""",
uuid.UUID(microdao_id),
token_symbol,
balance
)
return TreasuryItem(
token_symbol=row['token_symbol'],
balance=row['balance']
)
# ========================================================================
# Settings
# ========================================================================
async def get_settings(self, microdao_id: str) -> Dict[str, Any]:
"""Get all settings for a microDAO as dict"""
rows = await self.db.fetch(
"""
SELECT key, value
FROM microdao_settings
WHERE microdao_id = $1
""",
uuid.UUID(microdao_id)
)
return {row['key']: row['value'] for row in rows}
async def upsert_setting(
self,
microdao_id: str,
key: str,
value: Any
) -> None:
"""Upsert a single setting"""
await self.db.execute(
"""
INSERT INTO microdao_settings (microdao_id, key, value, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (microdao_id, key) DO UPDATE
SET value = $3, updated_at = NOW()
""",
uuid.UUID(microdao_id),
key,
value
)
async def delete_setting(self, microdao_id: str, key: str) -> bool:
"""Delete a setting"""
result = await self.db.execute(
"""
DELETE FROM microdao_settings
WHERE microdao_id = $1 AND key = $2
""",
uuid.UUID(microdao_id),
key
)
return result == "DELETE 1"

View File

@@ -0,0 +1,8 @@
fastapi==0.104.1
uvicorn==0.24.0
pydantic==2.5.0
asyncpg==0.29.0
httpx==0.25.1
python-dotenv==1.0.0
nats-py==2.6.0

View File

@@ -0,0 +1,320 @@
"""
MicroDAO Members Routes
Phase 7: Backend Completion
"""
from fastapi import APIRouter, HTTPException, Header
from typing import List, Optional
import httpx
import os
from datetime import datetime
from models import MicrodaoMember, MemberAdd, MemberUpdateRole
from repository_microdao import MicrodaoRepository
router = APIRouter(tags=["members"])
# Dependency injection (will be set in main.py)
repo: Optional[MicrodaoRepository] = None
# Service URLs
AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://localhost:7011")
PDP_SERVICE_URL = os.getenv("PDP_SERVICE_URL", "http://localhost:7012")
# NATS publisher
nats_publisher = None
# ============================================================================
# Auth & PDP Helpers
# ============================================================================
async def get_actor_from_token(authorization: Optional[str] = Header(None)):
"""Get ActorIdentity from auth-service"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid authorization header")
token = authorization.replace("Bearer ", "")
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{AUTH_SERVICE_URL}/auth/me",
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
raise HTTPException(status_code=401, detail="Invalid or expired token")
async def check_pdp_permission(
action: str,
resource: dict,
context: dict,
actor: dict
) -> bool:
"""Check permission via pdp-service"""
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{PDP_SERVICE_URL}/internal/pdp/evaluate",
json={
"action": action,
"resource": resource,
"context": context,
"actor": actor
}
)
response.raise_for_status()
result = response.json()
return result.get("decision") == "ALLOW"
except httpx.HTTPError as e:
print(f"⚠️ PDP error: {e}")
return False
# ============================================================================
# Members — List
# ============================================================================
@router.get("/microdao/{slug}/members", response_model=List[MicrodaoMember])
async def list_members(
slug: str,
authorization: Optional[str] = Header(None)
):
"""
List members of a microDAO
Requires: MICRODAO_READ permission
"""
actor = await get_actor_from_token(authorization)
# Get microDAO
microdao = await repo.get_microdao_by_slug(slug)
if not microdao:
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_READ",
resource={"type": "MICRODAO", "id": microdao.id},
context={},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot view members")
return await repo.list_members(microdao.id)
# ============================================================================
# Members — Add
# ============================================================================
@router.post("/microdao/{slug}/members", response_model=MicrodaoMember, status_code=201)
async def add_member(
slug: str,
data: MemberAdd,
authorization: Optional[str] = Header(None)
):
"""
Add member to microDAO
Requires: MICRODAO_MANAGE_MEMBERS permission
"""
actor = await get_actor_from_token(authorization)
# Get microDAO
microdao = await repo.get_microdao_by_slug(slug)
if not microdao:
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_MANAGE_MEMBERS",
resource={"type": "MICRODAO", "id": microdao.id},
context={"operation": "add_member"},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot add members")
# Validate role
valid_roles = ["owner", "admin", "member", "guest"]
if data.role not in valid_roles:
raise HTTPException(
status_code=400,
detail=f"Invalid role '{data.role}'. Must be one of: {', '.join(valid_roles)}"
)
# Add member
member = await repo.add_member(microdao.id, data.user_id, data.role)
# Publish NATS event
if nats_publisher:
await nats_publisher(
"microdao.event.member_added",
{
"microdao_id": microdao.id,
"slug": microdao.slug,
"member_id": member.id,
"user_id": data.user_id,
"role": data.role,
"actor_id": actor.get("user_id"),
"ts": datetime.now().isoformat()
}
)
return member
# ============================================================================
# Members — Update Role
# ============================================================================
@router.patch("/microdao/{slug}/members/{member_id}", response_model=MicrodaoMember)
async def update_member_role(
slug: str,
member_id: str,
data: MemberUpdateRole,
authorization: Optional[str] = Header(None)
):
"""
Update member role in microDAO
Requires: MICRODAO_MANAGE_MEMBERS permission
"""
actor = await get_actor_from_token(authorization)
# Get microDAO
microdao = await repo.get_microdao_by_slug(slug)
if not microdao:
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_MANAGE_MEMBERS",
resource={"type": "MICRODAO", "id": microdao.id},
context={"operation": "update_role"},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot update member roles")
# Validate role
valid_roles = ["owner", "admin", "member", "guest"]
if data.role not in valid_roles:
raise HTTPException(
status_code=400,
detail=f"Invalid role '{data.role}'. Must be one of: {', '.join(valid_roles)}"
)
# Get current members to find user_id
members = await repo.list_members(microdao.id)
member = next((m for m in members if m.id == member_id), None)
if not member:
raise HTTPException(status_code=404, detail=f"Member '{member_id}' not found")
# Remove old member and add with new role (simple approach)
await repo.remove_member(member_id)
updated_member = await repo.add_member(microdao.id, member.user_id, data.role)
# Publish NATS event
if nats_publisher:
await nats_publisher(
"microdao.event.member_role_updated",
{
"microdao_id": microdao.id,
"slug": microdao.slug,
"member_id": updated_member.id,
"user_id": member.user_id,
"old_role": member.role,
"new_role": data.role,
"actor_id": actor.get("user_id"),
"ts": datetime.now().isoformat()
}
)
return updated_member
# ============================================================================
# Members — Remove
# ============================================================================
@router.delete("/microdao/{slug}/members/{member_id}", status_code=204)
async def remove_member(
slug: str,
member_id: str,
authorization: Optional[str] = Header(None)
):
"""
Remove member from microDAO
Requires: MICRODAO_MANAGE_MEMBERS permission
"""
actor = await get_actor_from_token(authorization)
# Get microDAO
microdao = await repo.get_microdao_by_slug(slug)
if not microdao:
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_MANAGE_MEMBERS",
resource={"type": "MICRODAO", "id": microdao.id},
context={"operation": "remove_member"},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot remove members")
# Get member info before removal
members = await repo.list_members(microdao.id)
member = next((m for m in members if m.id == member_id), None)
if not member:
raise HTTPException(status_code=404, detail=f"Member '{member_id}' not found")
# Prevent removing the last owner
if member.role == "owner":
owners = [m for m in members if m.role == "owner"]
if len(owners) <= 1:
raise HTTPException(
status_code=400,
detail="Cannot remove the last owner. Transfer ownership first."
)
# Remove member
success = await repo.remove_member(member_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to remove member")
# Publish NATS event
if nats_publisher:
await nats_publisher(
"microdao.event.member_removed",
{
"microdao_id": microdao.id,
"slug": microdao.slug,
"member_id": member_id,
"user_id": member.user_id,
"role": member.role,
"actor_id": actor.get("user_id"),
"ts": datetime.now().isoformat()
}
)
return None
# ============================================================================
# Helper: Set repository
# ============================================================================
def set_repository(repository: MicrodaoRepository):
"""Set repository instance (called from main.py)"""
global repo
repo = repository
def set_nats_publisher(publisher_func):
"""Set NATS publisher function (called from main.py)"""
global nats_publisher
nats_publisher = publisher_func

View File

@@ -0,0 +1,300 @@
"""
MicroDAO CRUD Routes
Phase 7: Backend Completion
"""
from fastapi import APIRouter, HTTPException, Header
from typing import List, Optional
import httpx
import os
from models import MicrodaoCreate, MicrodaoUpdate, MicrodaoRead
from repository_microdao import MicrodaoRepository
router = APIRouter(prefix="/microdao", tags=["microdao"])
# Dependency injection (will be set in main.py)
repo: Optional[MicrodaoRepository] = None
# Service URLs
AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://localhost:7011")
PDP_SERVICE_URL = os.getenv("PDP_SERVICE_URL", "http://localhost:7012")
# NATS publisher (will be set in main.py)
nats_publisher = None
# ============================================================================
# Auth & PDP Helpers
# ============================================================================
async def get_actor_from_token(authorization: Optional[str] = Header(None)):
"""
Get ActorIdentity from auth-service
Returns actor dict or raises 401
"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid authorization header")
token = authorization.replace("Bearer ", "")
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{AUTH_SERVICE_URL}/auth/me",
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
raise HTTPException(status_code=401, detail="Invalid or expired token")
async def check_pdp_permission(
action: str,
resource: dict,
context: dict,
actor: dict
) -> bool:
"""
Check permission via pdp-service
Returns True if allowed, False otherwise
"""
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{PDP_SERVICE_URL}/internal/pdp/evaluate",
json={
"action": action,
"resource": resource,
"context": context,
"actor": actor
}
)
response.raise_for_status()
result = response.json()
return result.get("decision") == "ALLOW"
except httpx.HTTPError as e:
print(f"⚠️ PDP error: {e}")
return False # Fail closed
# ============================================================================
# CRUD — List
# ============================================================================
@router.get("", response_model=List[MicrodaoRead])
async def list_microdaos(authorization: Optional[str] = Header(None)):
"""
List microDAOs where current user is a member
Requires: valid auth token
"""
actor = await get_actor_from_token(authorization)
user_id = actor.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid actor identity")
return await repo.list_microdaos_for_user(user_id)
# ============================================================================
# CRUD — Create
# ============================================================================
@router.post("", response_model=MicrodaoRead, status_code=201)
async def create_microdao(
data: MicrodaoCreate,
authorization: Optional[str] = Header(None)
):
"""
Create new microDAO
Requires: MICRODAO_CREATE permission
"""
actor = await get_actor_from_token(authorization)
user_id = actor.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid actor identity")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_CREATE",
resource={"type": "MICRODAO"},
context={"operation": "create"},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot create microDAO")
# Check if slug already exists
existing = await repo.get_microdao_by_slug(data.slug)
if existing:
raise HTTPException(status_code=409, detail=f"MicroDAO with slug '{data.slug}' already exists")
# Create microDAO
microdao = await repo.create_microdao(data, user_id)
# Publish NATS event
if nats_publisher:
await nats_publisher(
"microdao.event.created",
{
"microdao_id": microdao.id,
"slug": microdao.slug,
"name": microdao.name,
"owner_user_id": user_id,
"actor_id": user_id,
"ts": microdao.created_at.isoformat()
}
)
return microdao
# ============================================================================
# CRUD — Read (by slug)
# ============================================================================
@router.get("/{slug}", response_model=MicrodaoRead)
async def get_microdao(
slug: str,
authorization: Optional[str] = Header(None)
):
"""
Get microDAO by slug
Requires: MICRODAO_READ permission
"""
actor = await get_actor_from_token(authorization)
# Get microDAO
microdao = await repo.get_microdao_by_slug(slug)
if not microdao:
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_READ",
resource={"type": "MICRODAO", "id": microdao.id},
context={},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot read this microDAO")
return microdao
# ============================================================================
# CRUD — Update
# ============================================================================
@router.put("/{slug}", response_model=MicrodaoRead)
async def update_microdao(
slug: str,
data: MicrodaoUpdate,
authorization: Optional[str] = Header(None)
):
"""
Update microDAO
Requires: MICRODAO_MANAGE permission
"""
actor = await get_actor_from_token(authorization)
# Get microDAO
microdao = await repo.get_microdao_by_slug(slug)
if not microdao:
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_MANAGE",
resource={"type": "MICRODAO", "id": microdao.id},
context={"operation": "update"},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot manage this microDAO")
# Update
updated = await repo.update_microdao(microdao.id, data)
if not updated:
raise HTTPException(status_code=500, detail="Failed to update microDAO")
# Publish NATS event
if nats_publisher:
await nats_publisher(
"microdao.event.updated",
{
"microdao_id": updated.id,
"slug": updated.slug,
"actor_id": actor.get("user_id"),
"changes": data.model_dump(exclude_unset=True),
"ts": updated.updated_at.isoformat()
}
)
return updated
# ============================================================================
# CRUD — Delete (Soft)
# ============================================================================
@router.delete("/{slug}", status_code=204)
async def delete_microdao(
slug: str,
authorization: Optional[str] = Header(None)
):
"""
Soft delete microDAO (set is_active = false)
Requires: MICRODAO_MANAGE permission
"""
actor = await get_actor_from_token(authorization)
# Get microDAO
microdao = await repo.get_microdao_by_slug(slug)
if not microdao:
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_MANAGE",
resource={"type": "MICRODAO", "id": microdao.id},
context={"operation": "delete"},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot delete this microDAO")
# Delete
success = await repo.delete_microdao(microdao.id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete microDAO")
# Publish NATS event
if nats_publisher:
await nats_publisher(
"microdao.event.deleted",
{
"microdao_id": microdao.id,
"slug": microdao.slug,
"actor_id": actor.get("user_id"),
"ts": datetime.now().isoformat()
}
)
return None
# ============================================================================
# Helper: Set repository
# ============================================================================
def set_repository(repository: MicrodaoRepository):
"""Set repository instance (called from main.py)"""
global repo
repo = repository
def set_nats_publisher(publisher_func):
"""Set NATS publisher function (called from main.py)"""
global nats_publisher
nats_publisher = publisher_func

View File

@@ -0,0 +1,273 @@
"""
MicroDAO Settings Routes
Phase 7: Backend Completion
"""
from fastapi import APIRouter, HTTPException, Header
from typing import Dict, Any, Optional
import httpx
import os
from datetime import datetime
from models import SettingItem
from repository_microdao import MicrodaoRepository
router = APIRouter(tags=["settings"])
# Dependency injection (will be set in main.py)
repo: Optional[MicrodaoRepository] = None
# Service URLs
AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://localhost:7011")
PDP_SERVICE_URL = os.getenv("PDP_SERVICE_URL", "http://localhost:7012")
# NATS publisher
nats_publisher = None
# ============================================================================
# Auth & PDP Helpers
# ============================================================================
async def get_actor_from_token(authorization: Optional[str] = Header(None)):
"""Get ActorIdentity from auth-service"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid authorization header")
token = authorization.replace("Bearer ", "")
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{AUTH_SERVICE_URL}/auth/me",
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
raise HTTPException(status_code=401, detail="Invalid or expired token")
async def check_pdp_permission(
action: str,
resource: dict,
context: dict,
actor: dict
) -> bool:
"""Check permission via pdp-service"""
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{PDP_SERVICE_URL}/internal/pdp/evaluate",
json={
"action": action,
"resource": resource,
"context": context,
"actor": actor
}
)
response.raise_for_status()
result = response.json()
return result.get("decision") == "ALLOW"
except httpx.HTTPError as e:
print(f"⚠️ PDP error: {e}")
return False
# ============================================================================
# Settings — Read
# ============================================================================
@router.get("/microdao/{slug}/settings", response_model=Dict[str, Any])
async def get_settings(
slug: str,
authorization: Optional[str] = Header(None)
):
"""
Get all settings for a microDAO
Requires: MICRODAO_READ permission
"""
actor = await get_actor_from_token(authorization)
# Get microDAO
microdao = await repo.get_microdao_by_slug(slug)
if not microdao:
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_READ",
resource={"type": "MICRODAO", "id": microdao.id},
context={},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot view settings")
return await repo.get_settings(microdao.id)
# ============================================================================
# Settings — Update (Single Setting)
# ============================================================================
@router.post("/microdao/{slug}/settings", status_code=204)
async def update_setting(
slug: str,
setting: SettingItem,
authorization: Optional[str] = Header(None)
):
"""
Update a single setting
Requires: MICRODAO_MANAGE permission
"""
actor = await get_actor_from_token(authorization)
# Get microDAO
microdao = await repo.get_microdao_by_slug(slug)
if not microdao:
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_MANAGE",
resource={"type": "MICRODAO", "id": microdao.id},
context={"operation": "update_settings"},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot manage settings")
# Update setting
await repo.upsert_setting(microdao.id, setting.key, setting.value)
# Publish NATS event
if nats_publisher:
await nats_publisher(
"microdao.event.settings_updated",
{
"microdao_id": microdao.id,
"slug": microdao.slug,
"setting_key": setting.key,
"actor_id": actor.get("user_id"),
"ts": datetime.now().isoformat()
}
)
return None
# ============================================================================
# Settings — Bulk Update
# ============================================================================
@router.put("/microdao/{slug}/settings", status_code=204)
async def update_settings_bulk(
slug: str,
settings: Dict[str, Any],
authorization: Optional[str] = Header(None)
):
"""
Update multiple settings at once
Requires: MICRODAO_MANAGE permission
"""
actor = await get_actor_from_token(authorization)
# Get microDAO
microdao = await repo.get_microdao_by_slug(slug)
if not microdao:
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_MANAGE",
resource={"type": "MICRODAO", "id": microdao.id},
context={"operation": "update_settings"},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot manage settings")
# Update all settings
for key, value in settings.items():
await repo.upsert_setting(microdao.id, key, value)
# Publish NATS event
if nats_publisher:
await nats_publisher(
"microdao.event.settings_updated",
{
"microdao_id": microdao.id,
"slug": microdao.slug,
"updated_keys": list(settings.keys()),
"actor_id": actor.get("user_id"),
"ts": datetime.now().isoformat()
}
)
return None
# ============================================================================
# Settings — Delete
# ============================================================================
@router.delete("/microdao/{slug}/settings/{key}", status_code=204)
async def delete_setting(
slug: str,
key: str,
authorization: Optional[str] = Header(None)
):
"""
Delete a setting
Requires: MICRODAO_MANAGE permission
"""
actor = await get_actor_from_token(authorization)
# Get microDAO
microdao = await repo.get_microdao_by_slug(slug)
if not microdao:
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_MANAGE",
resource={"type": "MICRODAO", "id": microdao.id},
context={"operation": "delete_setting"},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot manage settings")
# Delete setting
success = await repo.delete_setting(microdao.id, key)
if not success:
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
# Publish NATS event
if nats_publisher:
await nats_publisher(
"microdao.event.setting_deleted",
{
"microdao_id": microdao.id,
"slug": microdao.slug,
"setting_key": key,
"actor_id": actor.get("user_id"),
"ts": datetime.now().isoformat()
}
)
return None
# ============================================================================
# Helper: Set repository
# ============================================================================
def set_repository(repository: MicrodaoRepository):
"""Set repository instance (called from main.py)"""
global repo
repo = repository
def set_nats_publisher(publisher_func):
"""Set NATS publisher function (called from main.py)"""
global nats_publisher
nats_publisher = publisher_func

View File

@@ -0,0 +1,236 @@
"""
MicroDAO Treasury Routes
Phase 7: Backend Completion
"""
from fastapi import APIRouter, HTTPException, Header
from typing import List, Optional
from decimal import Decimal
import httpx
import os
from datetime import datetime
from models import TreasuryItem, TreasuryUpdate
from repository_microdao import MicrodaoRepository
router = APIRouter(tags=["treasury"])
# Dependency injection (will be set in main.py)
repo: Optional[MicrodaoRepository] = None
# Service URLs
AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://localhost:7011")
PDP_SERVICE_URL = os.getenv("PDP_SERVICE_URL", "http://localhost:7012")
# NATS publisher
nats_publisher = None
# ============================================================================
# Auth & PDP Helpers
# ============================================================================
async def get_actor_from_token(authorization: Optional[str] = Header(None)):
"""Get ActorIdentity from auth-service"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid authorization header")
token = authorization.replace("Bearer ", "")
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{AUTH_SERVICE_URL}/auth/me",
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
raise HTTPException(status_code=401, detail="Invalid or expired token")
async def check_pdp_permission(
action: str,
resource: dict,
context: dict,
actor: dict
) -> bool:
"""Check permission via pdp-service"""
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{PDP_SERVICE_URL}/internal/pdp/evaluate",
json={
"action": action,
"resource": resource,
"context": context,
"actor": actor
}
)
response.raise_for_status()
result = response.json()
return result.get("decision") == "ALLOW"
except httpx.HTTPError as e:
print(f"⚠️ PDP error: {e}")
return False
# ============================================================================
# Treasury — Read
# ============================================================================
@router.get("/microdao/{slug}/treasury", response_model=List[TreasuryItem])
async def get_treasury(
slug: str,
authorization: Optional[str] = Header(None)
):
"""
Get treasury balances for a microDAO
Requires: MICRODAO_READ_TREASURY permission
"""
actor = await get_actor_from_token(authorization)
# Get microDAO
microdao = await repo.get_microdao_by_slug(slug)
if not microdao:
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_READ_TREASURY",
resource={"type": "MICRODAO", "id": microdao.id},
context={},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot view treasury")
return await repo.get_treasury_items(microdao.id)
# ============================================================================
# Treasury — Update (Delta)
# ============================================================================
@router.post("/microdao/{slug}/treasury", response_model=TreasuryItem)
async def update_treasury(
slug: str,
token_symbol: str,
delta: Decimal,
authorization: Optional[str] = Header(None)
):
"""
Apply delta to treasury balance
Requires: MICRODAO_MANAGE_TREASURY permission
Query params:
- token_symbol: Token to update (e.g., "DAARION", "USDT")
- delta: Amount to add (positive) or subtract (negative)
"""
actor = await get_actor_from_token(authorization)
# Get microDAO
microdao = await repo.get_microdao_by_slug(slug)
if not microdao:
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_MANAGE_TREASURY",
resource={"type": "MICRODAO", "id": microdao.id},
context={"operation": "update_balance"},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot manage treasury")
# Apply delta
try:
treasury_item = await repo.apply_treasury_delta(microdao.id, token_symbol, delta)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Publish NATS event
if nats_publisher:
await nats_publisher(
"microdao.event.treasury_updated",
{
"microdao_id": microdao.id,
"slug": microdao.slug,
"token_symbol": token_symbol,
"delta": str(delta),
"new_balance": str(treasury_item.balance),
"actor_id": actor.get("user_id"),
"ts": datetime.now().isoformat()
}
)
return treasury_item
# ============================================================================
# Treasury — Set Balance (Direct)
# ============================================================================
@router.put("/microdao/{slug}/treasury/{token_symbol}", response_model=TreasuryItem)
async def set_treasury_balance(
slug: str,
token_symbol: str,
balance: Decimal,
authorization: Optional[str] = Header(None)
):
"""
Set treasury balance directly (admin operation)
Requires: MICRODAO_MANAGE_TREASURY permission
"""
actor = await get_actor_from_token(authorization)
# Get microDAO
microdao = await repo.get_microdao_by_slug(slug)
if not microdao:
raise HTTPException(status_code=404, detail=f"MicroDAO '{slug}' not found")
# Check PDP
allowed = await check_pdp_permission(
action="MICRODAO_MANAGE_TREASURY",
resource={"type": "MICRODAO", "id": microdao.id},
context={"operation": "set_balance"},
actor=actor
)
if not allowed:
raise HTTPException(status_code=403, detail="Permission denied: cannot manage treasury")
# Set balance
try:
treasury_item = await repo.set_treasury_balance(microdao.id, token_symbol, balance)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Publish NATS event
if nats_publisher:
await nats_publisher(
"microdao.event.treasury_updated",
{
"microdao_id": microdao.id,
"slug": microdao.slug,
"token_symbol": token_symbol,
"operation": "set_balance",
"new_balance": str(treasury_item.balance),
"actor_id": actor.get("user_id"),
"ts": datetime.now().isoformat()
}
)
return treasury_item
# ============================================================================
# Helper: Set repository
# ============================================================================
def set_repository(repository: MicrodaoRepository):
"""Set repository instance (called from main.py)"""
global repo
repo = repository
def set_nats_publisher(publisher_func):
"""Set NATS publisher function (called from main.py)"""
global nats_publisher
nats_publisher = publisher_func