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>
This commit is contained in:
2026-08-14 08:12:41 -06:00
co-authored by Claude Sonnet 5
parent 4aa8ef89c5
commit 0ab23c44a7
41 changed files with 2607 additions and 137 deletions
+7 -39
View File
@@ -2,10 +2,10 @@ import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import AdminAuditLog, Room, RoomMembership, RoomRole, User
from app.models import Room, RoomMembership, RoomRole, User
from app.security import hash_password
from app.services.audit import record_audit_log
class UserNotFoundError(Exception):
@@ -52,25 +52,6 @@ async def _get_membership(
return membership
def _log(
db: AsyncSession,
actor: User,
action: str,
target_type: str,
target_id: uuid.UUID,
metadata: dict | None = None,
) -> None:
db.add(
AdminAuditLog(
actor_id=actor.id,
action=action,
target_type=target_type,
target_id=target_id,
metadata_=metadata,
)
)
async def list_users(db: AsyncSession) -> list[User]:
result = await db.execute(select(User).order_by(User.created_at))
return list(result.scalars().all())
@@ -83,7 +64,7 @@ async def set_user_active(
raise CannotActOnSelfError()
user = await _get_user(db, target_user_id)
user.is_active = active
_log(db, actor, "user.activate" if active else "user.deactivate", "user", user.id)
record_audit_log(db, actor, "user.activate" if active else "user.deactivate", "user", user.id)
await db.commit()
await db.refresh(user)
return user
@@ -94,7 +75,7 @@ async def reset_user_password(
) -> None:
user = await _get_user(db, target_user_id)
user.password_hash = hash_password(new_password)
_log(db, actor, "user.reset_password", "user", user.id)
record_audit_log(db, actor, "user.reset_password", "user", user.id)
await db.commit()
@@ -105,7 +86,7 @@ async def set_user_site_admin(
raise CannotActOnSelfError()
user = await _get_user(db, target_user_id)
user.is_site_admin = is_admin
_log(db, actor, "user.promote" if is_admin else "user.demote", "user", user.id)
record_audit_log(db, actor, "user.promote" if is_admin else "user.demote", "user", user.id)
await db.commit()
await db.refresh(user)
return user
@@ -126,7 +107,7 @@ async def set_room_archived(
) -> Room:
room = await _get_room(db, room_id)
room.is_archived = archived
_log(db, actor, "room.archive" if archived else "room.unarchive", "room", room.id)
record_audit_log(db, actor, "room.archive" if archived else "room.unarchive", "room", room.id)
await db.commit()
await db.refresh(room)
return room
@@ -145,7 +126,7 @@ async def transfer_ownership_admin(
new_owner_membership.role = RoomRole.owner
current_owner_membership.role = RoomRole.admin
room.owner_id = new_owner_id
_log(
record_audit_log(
db,
actor,
"room.transfer_ownership",
@@ -156,16 +137,3 @@ async def transfer_ownership_admin(
await db.commit()
await db.refresh(room)
return room
async def list_audit_log(
db: AsyncSession, limit: int = 50, offset: int = 0
) -> list[AdminAuditLog]:
result = await db.execute(
select(AdminAuditLog)
.options(selectinload(AdminAuditLog.actor))
.order_by(AdminAuditLog.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())