Files
ds-chat/backend/app/routers/rooms.py
T
ksmithandClaude Sonnet 5 0ab23c44a7 Phase 7: Bot/extension system
Bot accounts (User rows with is_bot=True), scoped API tokens (read:messages,
write:messages, manage:rooms) authenticated via Authorization: Bearer on both
REST and the WS handshake, live bot WebSocket access on the same /ws/chat
endpoint humans use, message editing (WS "edit" envelope -> message_update
broadcast, fans out cross-instance for free via the existing broadcaster),
incoming webhooks (room-scoped, no auth beyond the URL token), and outgoing
webhooks/event subscriptions (HMAC-SHA256 signed, backgrounded delivery,
creation-time SSRF validation against private/loopback/link-local targets).

Token auth is additive, not a parallel system: a bearer-token-authenticated
bot goes through the exact same room-membership/role checks a session-
authenticated human does everywhere; only read:messages/write:messages are
separately scope-gated (the two message endpoints). manage:rooms scope
enforcement, full per-delivery SSRF re-validation, and bot API rate limiting
were explicitly scoped out (confirmed with the repo owner) as disproportionate
to this phase -- documented as known gaps in backend/README.md rather than
silently skipped.

Admin portal gains a Bots tab (create bots, issue/revoke scoped tokens,
cross-room webhook visibility); RoomInfoPanel gains room-scoped webhook/
subscription management, mirroring how invites already work there. The chat
UI also gets a minimal "edit your own message" affordance -- not asked for
by the issue, but the only practical way to exercise the edit pipeline by
hand instead of only via a scripted bot client.

Along the way: fixed a real bug caught while writing the incoming-webhook
test -- offline-push notification relied on the sender being "connected" to
exclude themselves, true for WS-originated messages but not for the new
webhook path, which has no WS connection for the attributed sender at all.
Now explicitly excluded. Also discovered the REST-only test fixture never
triggered ASGI lifespan, so app.state.broadcaster/presence didn't exist for
it; moved their construction out of the lifespan into create_app() itself
(Redis client construction is synchronous/lazy) so both the WS and
REST-only paths always have them.

New tests/test_bots.py, test_message_edit.py, test_webhooks.py (full suite
now 78/78, stable across repeated runs) plus a scripted end-to-end smoke
test (bot WS join/post/edit, incoming webhook, SSRF rejection, outgoing
delivery) and a full browser walkthrough of the new admin/room UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 08:12:41 -06:00

448 lines
15 KiB
Python

import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import (
get_current_user,
require_room_member,
require_room_role,
require_scope,
)
from app.models import RoomRole, User
from app.schemas.invite import InviteCreate, InviteRead
from app.schemas.message import MessageRead
from app.schemas.room import (
MyRoomItem,
RoomCreate,
RoomListItem,
RoomMemberRead,
RoomMemberRoleUpdate,
RoomRead,
RoomUpdate,
TransferOwnershipRequest,
)
from app.services.invite_service import (
AlreadyMemberError,
DuplicateInviteError,
InviteNotFoundError,
InviteNotPendingError,
TargetUserNotFoundError,
create_invite,
list_room_invites,
revoke_invite,
)
from app.schemas.webhook import (
EventSubscriptionCreate,
EventSubscriptionCreated,
EventSubscriptionRead,
WebhookIncomingCreate,
WebhookIncomingRead,
)
from app.services.message_service import list_recent_messages
from app.services.room_service import (
CannotRemoveOwnerError,
DuplicateRoomError,
InsufficientRoleError,
MembershipNotFoundError,
OwnerMustTransferError,
RoomIsPrivateError,
RoomNotFoundError,
change_member_role,
create_room,
delete_room,
get_room,
join_room,
leave_room,
list_member_rooms,
list_open_rooms,
list_room_members,
remove_member,
transfer_ownership,
update_room,
)
from app.services.webhook_service import (
InvalidEventTypeError,
SubscriptionNotFoundError,
WebhookNotFoundError,
create_event_subscription,
create_incoming_webhook,
list_event_subscriptions,
list_incoming_webhooks,
revoke_event_subscription,
revoke_incoming_webhook,
)
from app.services.ssrf import UnsafeWebhookUrlError
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
@router.post("", response_model=RoomRead, status_code=201)
async def create_room_endpoint(
data: RoomCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
return await create_room(db, current_user.id, data)
except DuplicateRoomError:
raise HTTPException(status_code=409, detail="A room with this name already exists")
@router.get("", response_model=list[RoomListItem])
async def list_rooms_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
rooms = await list_open_rooms(db, current_user.id)
return [
RoomListItem(
id=room.id,
name=room.name,
description=room.description,
is_private=room.is_private,
owner_id=room.owner_id,
created_at=room.created_at,
is_member=is_member,
)
for room, is_member in rooms
]
@router.get("/mine", response_model=list[MyRoomItem])
async def list_my_rooms_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
rooms = await list_member_rooms(db, current_user.id)
return [
MyRoomItem(
id=room.id,
name=room.name,
description=room.description,
is_private=room.is_private,
owner_id=room.owner_id,
created_at=room.created_at,
role=role,
)
for room, role in rooms
]
@router.patch("/{room_id}", response_model=RoomRead)
async def update_room_endpoint(
room_id: uuid.UUID,
data: RoomUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
room = await get_room(db, room_id)
await require_room_role(room_id, current_user, db, RoomRole.admin)
return await update_room(db, room, data)
except RoomNotFoundError:
raise HTTPException(status_code=404, detail="Room not found")
except DuplicateRoomError:
raise HTTPException(status_code=409, detail="A room with this name already exists")
@router.delete("/{room_id}", status_code=204)
async def delete_room_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
room = await get_room(db, room_id)
await require_room_role(room_id, current_user, db, RoomRole.owner)
await delete_room(db, room)
except RoomNotFoundError:
raise HTTPException(status_code=404, detail="Room not found")
@router.post("/{room_id}/join", response_model=RoomRead)
async def join_room_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
await join_room(db, room_id, current_user.id)
return await get_room(db, room_id)
except RoomNotFoundError:
raise HTTPException(status_code=404, detail="Room not found")
except RoomIsPrivateError:
raise HTTPException(status_code=400, detail="Cannot join a private room directly")
@router.post("/{room_id}/leave", status_code=204)
async def leave_room_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_member(room_id, current_user, db)
try:
await leave_room(db, room_id, current_user.id)
except OwnerMustTransferError:
raise HTTPException(
status_code=400, detail="Transfer ownership before leaving this room"
)
except MembershipNotFoundError:
raise HTTPException(status_code=404, detail="Not a member of this room")
@router.get("/{room_id}/members", response_model=list[RoomMemberRead])
async def list_room_members_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_member(room_id, current_user, db)
memberships = await list_room_members(db, room_id)
return [
RoomMemberRead(
user_id=m.user_id, username=m.user.username, role=m.role, joined_at=m.joined_at
)
for m in memberships
]
@router.delete("/{room_id}/members/{user_id}", status_code=204)
async def remove_member_endpoint(
room_id: uuid.UUID,
user_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
membership = await require_room_role(room_id, current_user, db, RoomRole.admin)
try:
await remove_member(db, room_id, user_id, acting_role=membership.role)
except MembershipNotFoundError:
raise HTTPException(status_code=404, detail="That user is not a member of this room")
except CannotRemoveOwnerError:
raise HTTPException(
status_code=400, detail="Room owner must transfer ownership before being removed"
)
except InsufficientRoleError:
raise HTTPException(status_code=403, detail="Only the owner can remove an admin")
@router.patch("/{room_id}/members/{user_id}", response_model=RoomMemberRead)
async def change_member_role_endpoint(
room_id: uuid.UUID,
user_id: uuid.UUID,
data: RoomMemberRoleUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.owner)
try:
membership = await change_member_role(db, room_id, user_id, data.role)
except MembershipNotFoundError:
raise HTTPException(status_code=404, detail="That user is not a member of this room")
except InsufficientRoleError:
raise HTTPException(
status_code=400, detail="Use transfer-ownership to change the room owner"
)
return RoomMemberRead(
user_id=membership.user_id,
username=membership.user.username,
role=membership.role,
joined_at=membership.joined_at,
)
@router.post("/{room_id}/transfer-ownership", response_model=RoomRead)
async def transfer_ownership_endpoint(
room_id: uuid.UUID,
data: TransferOwnershipRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
room = await get_room(db, room_id)
await require_room_role(room_id, current_user, db, RoomRole.owner)
return await transfer_ownership(db, room, current_user.id, data.new_owner_user_id)
except RoomNotFoundError:
raise HTTPException(status_code=404, detail="Room not found")
except MembershipNotFoundError:
raise HTTPException(
status_code=404, detail="The new owner must already be a member of this room"
)
@router.get("/{room_id}/messages", response_model=list[MessageRead])
async def get_room_messages_endpoint(
room_id: uuid.UUID,
request: Request,
limit: int = Query(default=50, ge=1, le=200),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_scope(request, "read:messages")
await require_room_member(room_id, current_user, db)
messages = await list_recent_messages(db, room_id, limit)
return [
MessageRead(
id=m.id,
room_id=m.room_id,
user_id=m.user_id,
username=m.user.username,
content=m.content,
created_at=m.created_at,
edited_at=m.edited_at,
)
for m in messages
]
def _to_invite_read(invite) -> InviteRead:
return InviteRead(
id=invite.id,
room_id=invite.room_id,
invited_by=invite.invited_by,
target_user_id=invite.target_user_id,
target_username=invite.target_user.username if invite.target_user else None,
status=invite.status,
expires_at=invite.expires_at,
created_at=invite.created_at,
)
@router.post("/{room_id}/invites", response_model=InviteRead, status_code=201)
async def create_invite_endpoint(
room_id: uuid.UUID,
data: InviteCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.admin)
try:
invite = await create_invite(db, room_id, current_user.id, data.target_username)
except TargetUserNotFoundError:
raise HTTPException(status_code=404, detail="No user with that username")
except AlreadyMemberError:
raise HTTPException(status_code=409, detail="That user is already a member")
except DuplicateInviteError:
raise HTTPException(status_code=409, detail="That user already has a pending invite")
return _to_invite_read(invite)
@router.get("/{room_id}/invites", response_model=list[InviteRead])
async def list_room_invites_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.admin)
invites = await list_room_invites(db, room_id)
return [_to_invite_read(i) for i in invites]
@router.delete("/{room_id}/invites/{invite_id}", status_code=204)
async def revoke_invite_endpoint(
room_id: uuid.UUID,
invite_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.admin)
try:
await revoke_invite(db, room_id, invite_id)
except InviteNotFoundError:
raise HTTPException(status_code=404, detail="Invite not found")
except InviteNotPendingError:
raise HTTPException(status_code=400, detail="Invite is no longer pending")
@router.post("/{room_id}/webhooks/incoming", response_model=WebhookIncomingRead, status_code=201)
async def create_incoming_webhook_endpoint(
room_id: uuid.UUID,
data: WebhookIncomingCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.admin)
return await create_incoming_webhook(db, current_user, room_id, data.description)
@router.get("/{room_id}/webhooks/incoming", response_model=list[WebhookIncomingRead])
async def list_incoming_webhooks_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.admin)
return await list_incoming_webhooks(db, room_id)
@router.delete("/{room_id}/webhooks/incoming/{webhook_id}", status_code=204)
async def revoke_incoming_webhook_endpoint(
room_id: uuid.UUID,
webhook_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.admin)
try:
await revoke_incoming_webhook(db, room_id, webhook_id)
except WebhookNotFoundError:
raise HTTPException(status_code=404, detail="Webhook not found")
@router.post(
"/{room_id}/event-subscriptions", response_model=EventSubscriptionCreated, status_code=201
)
async def create_event_subscription_endpoint(
room_id: uuid.UUID,
data: EventSubscriptionCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.admin)
try:
subscription, secret = await create_event_subscription(
db, current_user, room_id, data.event_types, data.target_url
)
except InvalidEventTypeError:
raise HTTPException(status_code=400, detail="Unrecognized event type")
except UnsafeWebhookUrlError:
raise HTTPException(
status_code=400, detail="target_url is not allowed (internal/private address)"
)
return EventSubscriptionCreated(
id=subscription.id,
room_id=subscription.room_id,
event_types=subscription.event_types,
target_url=subscription.target_url,
created_by=subscription.created_by,
created_at=subscription.created_at,
signing_secret=secret,
)
@router.get("/{room_id}/event-subscriptions", response_model=list[EventSubscriptionRead])
async def list_event_subscriptions_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.admin)
return await list_event_subscriptions(db, room_id)
@router.delete("/{room_id}/event-subscriptions/{subscription_id}", status_code=204)
async def revoke_event_subscription_endpoint(
room_id: uuid.UUID,
subscription_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.admin)
try:
await revoke_event_subscription(db, room_id, subscription_id)
except SubscriptionNotFoundError:
raise HTTPException(status_code=404, detail="Event subscription not found")