Make archiving a room actually affect existing members (#57)

is_archived was previously only exposed on the admin-only AdminRoom
schema and checked in one place (excluding a room from Browse rooms) --
for anyone already a member it was a complete no-op: still in their
sidebar, still fully postable, no indication anywhere it was archived.

Expose is_archived on the regular RoomRead/MyRoomItem schemas, drop
archived rooms from the sidebar list (while keeping them directly
reachable via URL so history stays readable), and reject new messages
in one -- both the WS "message" handler and incoming webhooks -- with a
clear "archived and read-only" response instead of silently no-op'ing
or a confusing membership error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 21:24:23 -06:00
co-authored by Claude Sonnet 5
parent 2fd055f7d6
commit 072405eb2d
10 changed files with 197 additions and 17 deletions
+2
View File
@@ -147,6 +147,7 @@ async def list_rooms_endpoint(
description=room.description,
is_private=room.is_private,
is_dm=room.is_dm,
is_archived=room.is_archived,
owner_id=room.owner_id,
created_at=room.created_at,
is_member=is_member,
@@ -171,6 +172,7 @@ async def list_my_rooms_endpoint(
description=room.description,
is_private=room.is_private,
is_dm=room.is_dm,
is_archived=room.is_archived,
owner_id=room.owner_id,
created_at=room.created_at,
role=role,
+3 -1
View File
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.webhook import IncomingWebhookPost
from app.services.message_events import broadcast_new_message
from app.services.webhook_service import WebhookNotFoundError, post_via_webhook
from app.services.webhook_service import RoomArchivedError, WebhookNotFoundError, post_via_webhook
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
@@ -22,6 +22,8 @@ async def incoming_webhook_endpoint(
message, room, sender = await post_via_webhook(db, token, data.content)
except WebhookNotFoundError:
raise HTTPException(status_code=404, detail="Unknown webhook")
except RoomArchivedError:
raise HTTPException(status_code=403, detail="This room has been archived and is read-only")
broadcaster = request.app.state.broadcaster
presence = request.app.state.presence
+5
View File
@@ -27,6 +27,11 @@ class RoomRead(BaseModel):
description: str | None
is_private: bool
is_dm: bool
# #57: previously only exposed on the admin-only AdminRoom schema, so a
# member of an archived room had no way to even know it was archived --
# the flag was set server-side but had no effect on their own view of
# the room at all.
is_archived: bool
owner_id: uuid.UUID
created_at: datetime
+9
View File
@@ -27,6 +27,10 @@ class SubscriptionNotFoundError(Exception):
pass
class RoomArchivedError(Exception):
pass
async def create_incoming_webhook(
db: AsyncSession, actor: User, room_id: uuid.UUID, description: str | None
) -> WebhookIncoming:
@@ -82,6 +86,11 @@ async def post_via_webhook(db: AsyncSession, token: str, content: str) -> tuple[
webhook = result.scalar_one_or_none()
if webhook is None:
raise WebhookNotFoundError()
# #57: same read-only rule as a human posting from the composer -- an
# archived room shouldn't gain new messages through a bot integration
# either.
if webhook.room.is_archived:
raise RoomArchivedError()
message = await create_message(db, webhook.room_id, webhook.created_by, content)
return message, webhook.room, webhook.creator
+15 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import ApiToken, Message, MessageFile, MessageImage, RoomMembership, User
from app.models import ApiToken, Message, MessageFile, MessageImage, Room, RoomMembership, User
from app.services.bot_service import resolve_token
from app.services.message_events import (
broadcast_dm_presence_update,
@@ -49,6 +49,11 @@ async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UU
return result.scalar_one_or_none() is not None
async def _is_room_archived(db: AsyncSession, room_id: uuid.UUID) -> bool:
room = await db.get(Room, room_id)
return room is not None and room.is_archived
def _missing_scope(api_token: ApiToken | None, scope: str) -> bool:
return api_token is not None and scope not in api_token.scopes
@@ -186,6 +191,15 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
{"type": "error", "detail": "Not a member of this room"}
)
continue
if await _is_room_archived(db, envelope.room_id):
# #57: history stays fully readable (joining/reading
# an archived room's channel is untouched above),
# this is the one gate that actually makes archiving
# do something for people who were already members.
await websocket.send_json(
{"type": "error", "detail": "This room has been archived and is read-only"}
)
continue
image_id = None
if envelope.image_id is not None:
image = await db.get(MessageImage, envelope.image_id)