diff --git a/backend/README.md b/backend/README.md
index 9eb0eb5..dbf28a8 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1,11 +1,13 @@
-# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6)
+# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7)
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
CRUD (open and private), room roles (owner/admin/member) and invites, a
WebSocket chat endpoint that fans out across multiple app-server instances
-via Redis pub/sub, Web Push notifications for offline room members, and a
-site-admin portal (user/room management + an audit log). See
-`../ARCHITECTURE.md` for the full system design and the phased build plan.
+via Redis pub/sub, Web Push notifications for offline room members, a
+site-admin portal (user/room/bot management + an audit log), and a bot/
+extension layer (scoped API tokens, live bot WebSocket access, incoming and
+outgoing webhooks, message editing). See `../ARCHITECTURE.md` for the full
+system design and the phased build plan.
This is an **invite-only site**: there is no public registration endpoint.
Accounts are created by an operator on the app server — see step 4 below.
@@ -108,15 +110,17 @@ app/
main.py create_app(), session middleware, router/WS mounting
config.py environment-driven settings (pydantic-settings)
database.py async engine/session, get_db() dependency
- dependencies.py get_current_user, require_room_member, require_room_role,
- require_site_admin
- security.py argon2 password hashing
+ dependencies.py get_current_user (session cookie or Bearer token),
+ require_room_member, require_room_role,
+ require_site_admin, require_scope
+ security.py argon2 password hashing + token generate/hash (sha256)
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
models/ SQLAlchemy models (users, rooms, room_memberships,
messages, room_invites, push_subscriptions,
- admin_audit_log)
+ admin_audit_log, api_tokens, webhooks_incoming,
+ event_subscriptions)
schemas/ Pydantic request/response models
- routers/ auth, rooms, invites, push, admin, health
+ routers/ auth, rooms, invites, push, admin, bots, webhooks, health
services/ business logic called by routers
ws/ connection_manager (local sockets), presence +
broadcaster (Redis), /ws/chat handler
@@ -147,15 +151,90 @@ Every `/api/admin/*` route (`app/routers/admin.py`) requires
(actor, action, target type/id, JSON metadata) in the same transaction as
the change, listed newest-first via `GET /api/admin/audit-log`.
-Two items from the original phase scope are deliberately not here yet:
-- **Bot/integration management** — nothing to manage until Phase 7 builds
- the actual bot data model (`api_tokens`, `webhooks_incoming`,
- `event_subscriptions` per `ARCHITECTURE.md` §4); it'll be built alongside
- that data model instead of as an empty panel now.
+Bot/integration management (deferred from this phase originally) is now in
+place — see Phase 7 below. One item is still deliberately not here:
- **System settings** — no settings storage or concrete setting exists yet.
The frontend has an empty "Settings" tab as a placeholder for when one
does.
+## Bot/extension system (Phase 7)
+
+Bots are `User` rows with `is_bot=True` (`app/services/bot_service.py`,
+admin-only, `/api/admin/bots/*`) — a generated-and-discarded password since
+bots never log in with one, and a `{username}@bots.example.com` placeholder
+email (`.local`/`.invalid` are rejected by `EmailStr`'s special-use-TLD
+check; a subdomain of the real, if reserved-for-docs, `.com` isn't). A bot
+authenticates instead with a **scoped API token** (`read:messages`,
+`write:messages`, `manage:rooms`) — shown once at issuance, stored as a
+SHA-256 hash (`security.hash_token`, deliberately *not* argon2: a bearer
+token has to be looked up by itself with no username to key off first,
+which argon2's per-call random salt makes impossible; a fast hash of a
+256-bit random token is the standard approach, same as GitHub/Stripe keys).
+
+**Auth**: `get_current_user` (`app/dependencies.py`) checks for an
+`Authorization: Bearer` header before falling back to the session cookie;
+a resolved token is stashed on `request.state.api_token` so `require_scope`
+can gate specific actions. A token-authenticated bot is subject to the
+*exact same* room-membership/role checks as a session-authenticated human
+on every existing endpoint — the token only narrows things further, it
+doesn't grant anything a plain room membership wouldn't. Only
+`read:messages`/`write:messages` are actually scope-gated (on
+`GET /api/rooms/{id}/messages` and the WS message/edit handlers) —
+`manage:rooms` is a recognized, issuable scope with no separate enforcement
+yet, so a bot's room-management ability is bounded by its ordinary room
+role, same as any user; wiring real `manage:rooms` gating into the dozen
+room-management endpoints was cut from this phase's scope (confirmed with
+the repo owner) as disproportionate to the win. The WS handshake
+(`app/ws/chat.py`) accepts the same header directly (bots set it on the
+handshake; browsers use the cookie) — same `/ws/chat` endpoint a human
+client uses, per `ARCHITECTURE.md`'s "same connection type" design.
+
+**Message editing**: `{"type": "edit", "room_id", "message_id", "content"}`
+over the existing WS connection (`message_service.edit_message` — 403 if
+you're not the author), broadcasts `{"type": "message_update", ...}` via the
+same `RoomBroadcaster.publish()` new messages use, so it fans out
+cross-instance for free. `Message.edited_at` (present in the schema since
+Phase 1, unused until now) is exposed on `MessageRead`. The frontend also
+gets a minimal "edit your own message" UI affordance (hover a bubble you
+own) — not asked for by the issue, but the only practical way to exercise
+the pipeline by hand instead of only via a scripted bot client, and it's a
+small addition once the WS envelope exists anyway.
+
+**Incoming webhooks** (`POST /api/rooms/{id}/webhooks/incoming`, room-admin
+managed, mirrors how invites are nested under rooms): a room-scoped URL
+with no auth beyond the token in it being correct
+(`webhooks_incoming.token` is stored **in the clear**, unlike API tokens —
+the room admin needs to view/copy the full URL anytime). `POST
+/api/webhooks/incoming/{token}` (public, no auth dependency) creates a
+message attributed to the webhook's creator and runs the identical
+post-message pipeline a WS-originated message does
+(`app/services/message_events.py`'s `broadcast_new_message`, shared by both
+call sites rather than duplicated).
+
+**Outgoing webhooks / event subscriptions** (`POST
+/api/rooms/{id}/event-subscriptions`, room-admin managed; room-scoped or
+global via `room_id=null`): fires an HMAC-SHA256-signed POST
+(`X-KeepItTalking-Signature: sha256=...`) on `message.created`/
+`message.updated`, delivered via a backgrounded `asyncio.create_task`
+(`app/services/webhook_delivery.py`) — safe to background here, unlike the
+Phase 4 push lesson, since there's no DB session involved, just the
+already-serialized payload and secret. Fire-once, no retry/backoff — a
+failed delivery is logged and dropped, documented limitation, not a
+guarantee.
+
+**SSRF protection** (`app/services/ssrf.py`): target URLs are validated at
+*subscription-creation time* — non-http(s) schemes rejected, hostname
+resolved and rejected if any address is private/loopback/link-local/
+reserved/multicast. Not re-validated per delivery, so DNS rebinding between
+creation and a later send isn't defended against — a real gap, deliberately
+left open (confirmed with the repo owner) rather than building the
+meaningfully more involved per-request IP-pinning that would close it.
+
+**Rate limiting**: `ARCHITECTURE.md` calls for rate-limiting bot API calls
+the same as human ones. Not implemented — there's no rate limiting
+anywhere in the app today (human or bot) to extend, and building one well
+is its own scope. Documented gap, not an oversight.
+
## Cross-instance broadcast (Phase 5)
The WebSocket layer is split into three pieces so that running one app
@@ -186,15 +265,18 @@ below.
`POST /api/push/subscribe` (upserts by `endpoint`) / `DELETE /api/push/subscribe`
manage a user's `push_subscriptions` rows; `GET /api/push/vapid-public-key` gives
the frontend the key it needs for `PushManager.subscribe()`. On every chat
-message, `app/ws/chat.py` computes `room members - Presence.
-connected_user_ids(room_id)` (who's actually connected to *that room* right
-now, across every app instance — see Phase 5 below) and sends each offline
-member a push via `pywebpush`, awaited inline against the same
-request-scoped session rather than fired as a background task — the
-broadcast to online members already happened by that point, so nothing
-online-facing is delayed, and it sidesteps `asyncio.create_task()`s outliving
-the session/event loop they were created on. An expired/invalid subscription
-(pywebpush 404/410) is deleted automatically.
+message, `app/services/message_events.py` computes `room members -
+Presence.connected_user_ids(room_id) - {sender}` (who's actually connected
+to *that room* right now, across every app instance — see Phase 5 below;
+the sender is subtracted explicitly rather than relied on to be "connected,"
+since that's only true for WS-originated messages, not the Phase 7
+incoming-webhook path) and sends each offline member a push via `pywebpush`,
+awaited inline against the same request-scoped session rather than fired as
+a background task — the broadcast to online members already happened by
+that point, so nothing online-facing is delayed, and it sidesteps
+`asyncio.create_task()`s outliving the session/event loop they were created
+on. An expired/invalid subscription (pywebpush 404/410) is deleted
+automatically.
## Room roles and invites (Phase 2)
diff --git a/backend/alembic/versions/3350c67553ad_bots_and_webhooks.py b/backend/alembic/versions/3350c67553ad_bots_and_webhooks.py
new file mode 100644
index 0000000..7aa914e
--- /dev/null
+++ b/backend/alembic/versions/3350c67553ad_bots_and_webhooks.py
@@ -0,0 +1,76 @@
+"""bots and webhooks
+
+Revision ID: 3350c67553ad
+Revises: 6ee71c8d5e07
+Create Date: 2026-08-14 07:47:25.761745
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '3350c67553ad'
+down_revision: Union[str, Sequence[str], None] = '6ee71c8d5e07'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('api_tokens',
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.Column('owner_id', sa.Uuid(), nullable=False),
+ sa.Column('token_hash', sa.String(length=64), nullable=False),
+ sa.Column('scopes', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
+ sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_api_tokens_owner_id'), 'api_tokens', ['owner_id'], unique=False)
+ op.create_index(op.f('ix_api_tokens_token_hash'), 'api_tokens', ['token_hash'], unique=True)
+ op.create_table('event_subscriptions',
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.Column('room_id', sa.Uuid(), nullable=True),
+ sa.Column('event_types', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
+ sa.Column('target_url', sa.String(length=2048), nullable=False),
+ sa.Column('signing_secret', sa.String(length=64), nullable=False),
+ sa.Column('created_by', sa.Uuid(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
+ sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_event_subscriptions_room_id'), 'event_subscriptions', ['room_id'], unique=False)
+ op.create_table('webhooks_incoming',
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.Column('room_id', sa.Uuid(), nullable=False),
+ sa.Column('token', sa.String(length=64), nullable=False),
+ sa.Column('created_by', sa.Uuid(), nullable=False),
+ sa.Column('description', sa.String(length=500), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
+ sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_webhooks_incoming_room_id'), 'webhooks_incoming', ['room_id'], unique=False)
+ op.create_index(op.f('ix_webhooks_incoming_token'), 'webhooks_incoming', ['token'], unique=True)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_webhooks_incoming_token'), table_name='webhooks_incoming')
+ op.drop_index(op.f('ix_webhooks_incoming_room_id'), table_name='webhooks_incoming')
+ op.drop_table('webhooks_incoming')
+ op.drop_index(op.f('ix_event_subscriptions_room_id'), table_name='event_subscriptions')
+ op.drop_table('event_subscriptions')
+ op.drop_index(op.f('ix_api_tokens_token_hash'), table_name='api_tokens')
+ op.drop_index(op.f('ix_api_tokens_owner_id'), table_name='api_tokens')
+ op.drop_table('api_tokens')
+ # ### end Alembic commands ###
diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py
index c882077..8bc6752 100644
--- a/backend/app/dependencies.py
+++ b/backend/app/dependencies.py
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import RoomMembership, RoomRole, User
+from app.services.bot_service import resolve_token
_ROLE_RANK = {RoomRole.member: 0, RoomRole.admin: 1, RoomRole.owner: 2}
@@ -13,6 +14,20 @@ _ROLE_RANK = {RoomRole.member: 0, RoomRole.admin: 1, RoomRole.owner: 2}
async def get_current_user(
request: Request, db: AsyncSession = Depends(get_db)
) -> User:
+ # Bearer token (bots) takes priority over the session cookie (humans) --
+ # a request either carries one or the other, never meaningfully both.
+ # Downstream, a token-authenticated bot is subject to the exact same
+ # room-membership/role checks as a session-authenticated human; the
+ # token additionally narrows what it can do via require_scope below.
+ auth_header = request.headers.get("authorization")
+ if auth_header and auth_header.lower().startswith("bearer "):
+ resolved = await resolve_token(db, auth_header[len("bearer ") :].strip())
+ if resolved is None:
+ raise HTTPException(status_code=401, detail="Invalid or revoked API token")
+ user, token = resolved
+ request.state.api_token = token
+ return user
+
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated")
@@ -25,6 +40,12 @@ async def get_current_user(
return user
+def require_scope(request: Request, scope: str) -> None:
+ token = getattr(request.state, "api_token", None)
+ if token is not None and scope not in token.scopes:
+ raise HTTPException(status_code=403, detail=f"Token missing required scope: {scope}")
+
+
async def require_room_member(
room_id: uuid.UUID, user: User, db: AsyncSession
) -> RoomMembership:
diff --git a/backend/app/main.py b/backend/app/main.py
index e3e83ce..8cb901d 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -8,7 +8,7 @@ from redis.asyncio import Redis
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
-from app.routers import admin, auth, health, invites, push, rooms
+from app.routers import admin, auth, bots, health, invites, push, rooms, webhooks
from app.ws.broadcaster import RoomBroadcaster
from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager
@@ -17,18 +17,22 @@ from app.ws.presence import Presence
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
- redis = Redis.from_url(settings.redis_url, decode_responses=True)
- app.state.presence = Presence(redis)
- broadcaster = RoomBroadcaster(redis, app.state.connection_manager)
- app.state.broadcaster = broadcaster
- listener_task = asyncio.create_task(broadcaster.listen())
+ # redis/presence/broadcaster are constructed in create_app(), not here --
+ # Redis.from_url() is synchronous/lazy (no connection opens until the
+ # first command), so app.state.presence/broadcaster are always present
+ # even for callers that never trigger the ASGI lifespan (e.g. httpx's
+ # ASGITransport, used by the plain REST test fixtures -- only
+ # TestClient's websocket_connect-based tests actually run lifespan).
+ # The background listener task genuinely needs a running event loop
+ # though, so that part stays here.
+ listener_task = asyncio.create_task(app.state.broadcaster.listen())
yield
listener_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await listener_task
- await redis.aclose()
+ await app.state.redis.aclose()
def create_app() -> FastAPI:
@@ -43,6 +47,9 @@ def create_app() -> FastAPI:
)
app.state.connection_manager = ConnectionManager()
+ app.state.redis = Redis.from_url(settings.redis_url, decode_responses=True)
+ app.state.presence = Presence(app.state.redis)
+ app.state.broadcaster = RoomBroadcaster(app.state.redis, app.state.connection_manager)
app.include_router(health.router)
app.include_router(auth.router)
@@ -50,6 +57,8 @@ def create_app() -> FastAPI:
app.include_router(invites.router)
app.include_router(push.router)
app.include_router(admin.router)
+ app.include_router(bots.router)
+ app.include_router(webhooks.router)
app.include_router(ws_router)
return app
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index e5954fb..49fc152 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -1,11 +1,14 @@
from app.models.admin_audit_log import AdminAuditLog
+from app.models.api_token import ApiToken
from app.models.base import Base
+from app.models.event_subscription import EventSubscription
from app.models.invite import InviteStatus, RoomInvite
from app.models.membership import RoomMembership, RoomRole
from app.models.message import Message
from app.models.push_subscription import PushSubscription
from app.models.room import Room
from app.models.user import User
+from app.models.webhook_incoming import WebhookIncoming
__all__ = [
"Base",
@@ -18,4 +21,7 @@ __all__ = [
"InviteStatus",
"PushSubscription",
"AdminAuditLog",
+ "ApiToken",
+ "WebhookIncoming",
+ "EventSubscription",
]
diff --git a/backend/app/models/api_token.py b/backend/app/models/api_token.py
new file mode 100644
index 0000000..3b752cd
--- /dev/null
+++ b/backend/app/models/api_token.py
@@ -0,0 +1,23 @@
+import uuid
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, String, func
+from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.models.base import Base
+
+
+class ApiToken(Base):
+ __tablename__ = "api_tokens"
+
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
+ owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
+ token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
+ scopes: Mapped[list[str]] = mapped_column(JSONB, nullable=False)
+ last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ owner = relationship("User")
diff --git a/backend/app/models/event_subscription.py b/backend/app/models/event_subscription.py
new file mode 100644
index 0000000..4cdb4b8
--- /dev/null
+++ b/backend/app/models/event_subscription.py
@@ -0,0 +1,25 @@
+import uuid
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, String, func
+from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.models.base import Base
+
+
+class EventSubscription(Base):
+ __tablename__ = "event_subscriptions"
+
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
+ room_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("rooms.id"), index=True)
+ event_types: Mapped[list[str]] = mapped_column(JSONB, nullable=False)
+ target_url: Mapped[str] = mapped_column(String(2048), nullable=False)
+ signing_secret: Mapped[str] = mapped_column(String(64), nullable=False)
+ created_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ room = relationship("Room")
+ creator = relationship("User")
diff --git a/backend/app/models/webhook_incoming.py b/backend/app/models/webhook_incoming.py
new file mode 100644
index 0000000..8e35ca2
--- /dev/null
+++ b/backend/app/models/webhook_incoming.py
@@ -0,0 +1,25 @@
+import uuid
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.models.base import Base
+
+
+class WebhookIncoming(Base):
+ __tablename__ = "webhooks_incoming"
+
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
+ room_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rooms.id"), index=True, nullable=False)
+ # Stored in the clear (not hashed) -- the whole point is the room admin
+ # can view/copy the full webhook URL again anytime, unlike an API token.
+ token: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
+ created_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
+ description: Mapped[str | None] = mapped_column(String(500))
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ room = relationship("Room")
+ creator = relationship("User")
diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py
index f451626..72ddedb 100644
--- a/backend/app/routers/admin.py
+++ b/backend/app/routers/admin.py
@@ -14,12 +14,12 @@ from app.schemas.admin import (
ResetPasswordRequest,
TransferOwnershipRequest,
)
+from app.schemas.webhook import EventSubscriptionAdminRead, WebhookIncomingAdminRead
from app.services.admin_service import (
CannotActOnSelfError,
RoomNotFoundError,
TargetNotRoomMemberError,
UserNotFoundError,
- list_audit_log,
list_rooms_admin,
list_users,
reset_user_password,
@@ -28,6 +28,11 @@ from app.services.admin_service import (
set_user_site_admin,
transfer_ownership_admin,
)
+from app.services.audit import list_audit_log
+from app.services.webhook_service import (
+ list_all_event_subscriptions_admin,
+ list_all_incoming_webhooks_admin,
+)
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -223,3 +228,47 @@ async def list_audit_log_endpoint(
)
for e in entries
]
+
+
+@router.get("/webhooks/incoming", response_model=list[WebhookIncomingAdminRead])
+async def list_incoming_webhooks_admin_endpoint(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ require_site_admin(current_user)
+ webhooks = await list_all_incoming_webhooks_admin(db)
+ return [
+ WebhookIncomingAdminRead(
+ id=w.id,
+ room_id=w.room_id,
+ token=w.token,
+ created_by=w.created_by,
+ description=w.description,
+ created_at=w.created_at,
+ room_name=w.room.name,
+ created_by_username=w.creator.username,
+ )
+ for w in webhooks
+ ]
+
+
+@router.get("/event-subscriptions", response_model=list[EventSubscriptionAdminRead])
+async def list_event_subscriptions_admin_endpoint(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ require_site_admin(current_user)
+ subscriptions = await list_all_event_subscriptions_admin(db)
+ return [
+ EventSubscriptionAdminRead(
+ id=s.id,
+ room_id=s.room_id,
+ event_types=s.event_types,
+ target_url=s.target_url,
+ created_by=s.created_by,
+ created_at=s.created_at,
+ room_name=s.room.name if s.room else None,
+ created_by_username=s.creator.username,
+ )
+ for s in subscriptions
+ ]
diff --git a/backend/app/routers/bots.py b/backend/app/routers/bots.py
new file mode 100644
index 0000000..d3860e7
--- /dev/null
+++ b/backend/app/routers/bots.py
@@ -0,0 +1,91 @@
+import uuid
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import get_db
+from app.dependencies import get_current_user, require_site_admin
+from app.models import User
+from app.schemas.bot import ApiTokenCreate, ApiTokenCreated, ApiTokenRead, BotCreate, BotRead
+from app.services.bot_service import (
+ BotNotFoundError,
+ DuplicateBotError,
+ InvalidScopeError,
+ TokenNotFoundError,
+ create_api_token,
+ create_bot,
+ list_api_tokens,
+ list_bots,
+ revoke_api_token,
+)
+
+router = APIRouter(prefix="/api/admin/bots", tags=["bots"])
+
+
+@router.post("", response_model=BotRead, status_code=201)
+async def create_bot_endpoint(
+ data: BotCreate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ require_site_admin(current_user)
+ try:
+ return await create_bot(db, current_user, data.username)
+ except DuplicateBotError:
+ raise HTTPException(status_code=409, detail="A user with this username already exists")
+
+
+@router.get("", response_model=list[BotRead])
+async def list_bots_endpoint(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ require_site_admin(current_user)
+ return await list_bots(db)
+
+
+@router.post("/{bot_id}/tokens", response_model=ApiTokenCreated, status_code=201)
+async def create_token_endpoint(
+ bot_id: uuid.UUID,
+ data: ApiTokenCreate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ require_site_admin(current_user)
+ try:
+ token, plaintext = await create_api_token(db, current_user, bot_id, data.scopes)
+ except BotNotFoundError:
+ raise HTTPException(status_code=404, detail="Bot not found")
+ except InvalidScopeError:
+ raise HTTPException(status_code=400, detail="Unrecognized scope")
+ return ApiTokenCreated(
+ id=token.id,
+ owner_id=token.owner_id,
+ scopes=token.scopes,
+ last_used_at=token.last_used_at,
+ created_at=token.created_at,
+ token=plaintext,
+ )
+
+
+@router.get("/{bot_id}/tokens", response_model=list[ApiTokenRead])
+async def list_tokens_endpoint(
+ bot_id: uuid.UUID,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ require_site_admin(current_user)
+ return await list_api_tokens(db, bot_id)
+
+
+@router.delete("/tokens/{token_id}", status_code=204)
+async def revoke_token_endpoint(
+ token_id: uuid.UUID,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ require_site_admin(current_user)
+ try:
+ await revoke_api_token(db, current_user, token_id)
+ except TokenNotFoundError:
+ raise HTTPException(status_code=404, detail="Token not found")
diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py
index eaba35b..dd7ba01 100644
--- a/backend/app/routers/rooms.py
+++ b/backend/app/routers/rooms.py
@@ -1,10 +1,15 @@
import uuid
-from fastapi import APIRouter, Depends, HTTPException, Query
+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
+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
@@ -28,6 +33,13 @@ from app.services.invite_service import (
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,
@@ -50,6 +62,18 @@ from app.services.room_service import (
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"])
@@ -252,10 +276,12 @@ async def transfer_ownership_endpoint(
@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 [
@@ -266,6 +292,7 @@ async def get_room_messages_endpoint(
username=m.user.username,
content=m.content,
created_at=m.created_at,
+ edited_at=m.edited_at,
)
for m in messages
]
@@ -328,3 +355,93 @@ async def revoke_invite_endpoint(
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")
diff --git a/backend/app/routers/webhooks.py b/backend/app/routers/webhooks.py
new file mode 100644
index 0000000..4c46e0f
--- /dev/null
+++ b/backend/app/routers/webhooks.py
@@ -0,0 +1,28 @@
+from fastapi import APIRouter, Depends, HTTPException, Request
+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
+
+router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
+
+
+@router.post("/incoming/{token}", status_code=204)
+async def incoming_webhook_endpoint(
+ token: str,
+ data: IncomingWebhookPost,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+) -> None:
+ # No auth dependency at all -- the token in the URL is the credential,
+ # per ARCHITECTURE.md's incoming-webhook design.
+ try:
+ message, room, sender = await post_via_webhook(db, token, data.content)
+ except WebhookNotFoundError:
+ raise HTTPException(status_code=404, detail="Unknown webhook")
+
+ broadcaster = request.app.state.broadcaster
+ presence = request.app.state.presence
+ await broadcast_new_message(db, broadcaster, presence, room.id, message, sender)
diff --git a/backend/app/schemas/bot.py b/backend/app/schemas/bot.py
new file mode 100644
index 0000000..9fd0a79
--- /dev/null
+++ b/backend/app/schemas/bot.py
@@ -0,0 +1,35 @@
+import uuid
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class BotCreate(BaseModel):
+ username: str = Field(min_length=3, max_length=50)
+
+
+class BotRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: uuid.UUID
+ username: str
+ is_active: bool
+ created_at: datetime
+
+
+class ApiTokenCreate(BaseModel):
+ scopes: list[str] = Field(min_length=1)
+
+
+class ApiTokenRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: uuid.UUID
+ owner_id: uuid.UUID
+ scopes: list[str]
+ last_used_at: datetime | None
+ created_at: datetime
+
+
+class ApiTokenCreated(ApiTokenRead):
+ token: str
diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py
index 82a9d51..7358ead 100644
--- a/backend/app/schemas/message.py
+++ b/backend/app/schemas/message.py
@@ -13,3 +13,4 @@ class MessageRead(BaseModel):
username: str
content: str
created_at: datetime
+ edited_at: datetime | None
diff --git a/backend/app/schemas/webhook.py b/backend/app/schemas/webhook.py
new file mode 100644
index 0000000..bfe4646
--- /dev/null
+++ b/backend/app/schemas/webhook.py
@@ -0,0 +1,53 @@
+import uuid
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class WebhookIncomingCreate(BaseModel):
+ description: str | None = Field(default=None, max_length=500)
+
+
+class WebhookIncomingRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: uuid.UUID
+ room_id: uuid.UUID
+ token: str
+ created_by: uuid.UUID
+ description: str | None
+ created_at: datetime
+
+
+class WebhookIncomingAdminRead(WebhookIncomingRead):
+ room_name: str
+ created_by_username: str
+
+
+class IncomingWebhookPost(BaseModel):
+ content: str = Field(min_length=1)
+
+
+class EventSubscriptionCreate(BaseModel):
+ event_types: list[str] = Field(min_length=1)
+ target_url: str = Field(min_length=1, max_length=2048)
+
+
+class EventSubscriptionRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: uuid.UUID
+ room_id: uuid.UUID | None
+ event_types: list[str]
+ target_url: str
+ created_by: uuid.UUID
+ created_at: datetime
+
+
+class EventSubscriptionCreated(EventSubscriptionRead):
+ signing_secret: str
+
+
+class EventSubscriptionAdminRead(EventSubscriptionRead):
+ room_name: str | None
+ created_by_username: str
diff --git a/backend/app/security.py b/backend/app/security.py
index a466dc4..cbe1517 100644
--- a/backend/app/security.py
+++ b/backend/app/security.py
@@ -1,3 +1,6 @@
+import hashlib
+import secrets
+
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
@@ -13,3 +16,16 @@ def verify_password(password: str, password_hash: str) -> bool:
return _hasher.verify(password_hash, password)
except VerifyMismatchError:
return False
+
+
+def generate_token() -> str:
+ return f"kit_{secrets.token_urlsafe(32)}"
+
+
+def hash_token(token: str) -> str:
+ # Deterministic (not argon2) is deliberate: a bearer token has to be
+ # looked up *by itself* (no username to look up first, unlike a
+ # password), and argon2's per-call random salt makes that impossible.
+ # A fast hash of a high-entropy 256-bit random token is the standard
+ # approach for API keys (same as GitHub/Stripe).
+ return hashlib.sha256(token.encode()).hexdigest()
diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py
index 7df6e77..4a462b6 100644
--- a/backend/app/services/admin_service.py
+++ b/backend/app/services/admin_service.py
@@ -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())
diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py
new file mode 100644
index 0000000..9ad3931
--- /dev/null
+++ b/backend/app/services/audit.py
@@ -0,0 +1,39 @@
+import uuid
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.models import AdminAuditLog, User
+
+
+def record_audit_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_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())
diff --git a/backend/app/services/bot_service.py b/backend/app/services/bot_service.py
new file mode 100644
index 0000000..350fb72
--- /dev/null
+++ b/backend/app/services/bot_service.py
@@ -0,0 +1,119 @@
+import uuid
+from datetime import datetime, timezone
+
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.models import ApiToken, User
+from app.security import generate_token, hash_password, hash_token
+from app.services.audit import record_audit_log
+
+VALID_SCOPES = {"read:messages", "write:messages", "manage:rooms"}
+
+
+class DuplicateBotError(Exception):
+ pass
+
+
+class BotNotFoundError(Exception):
+ pass
+
+
+class InvalidScopeError(Exception):
+ pass
+
+
+class TokenNotFoundError(Exception):
+ pass
+
+
+async def create_bot(db: AsyncSession, actor: User, username: str) -> User:
+ # Bots never authenticate with a password -- generate one and discard
+ # it. email is NOT NULL/unique today; a placeholder avoids widening the
+ # schema just for accounts that will never receive real mail.
+ # bots.example.com (not e.g. bots.local/.invalid) deliberately, since
+ # pydantic's EmailStr rejects addresses whose *top-level* domain is one
+ # of the IANA special-use TLDs (.local, .invalid, .test, ...) -- a
+ # subdomain of the real (if reserved-for-docs) .com TLD isn't affected.
+ bot = User(
+ username=username,
+ email=f"{username}@bots.example.com",
+ password_hash=hash_password(generate_token()),
+ is_bot=True,
+ )
+ db.add(bot)
+ try:
+ await db.flush()
+ except IntegrityError as exc:
+ await db.rollback()
+ raise DuplicateBotError() from exc
+
+ record_audit_log(db, actor, "bot.create", "user", bot.id)
+ await db.commit()
+ await db.refresh(bot)
+ return bot
+
+
+async def list_bots(db: AsyncSession) -> list[User]:
+ result = await db.execute(
+ select(User).where(User.is_bot.is_(True)).order_by(User.created_at)
+ )
+ return list(result.scalars().all())
+
+
+async def _get_bot(db: AsyncSession, bot_id: uuid.UUID) -> User:
+ bot = await db.get(User, bot_id)
+ if bot is None or not bot.is_bot:
+ raise BotNotFoundError()
+ return bot
+
+
+async def create_api_token(
+ db: AsyncSession, actor: User, bot_id: uuid.UUID, scopes: list[str]
+) -> tuple[ApiToken, str]:
+ bot = await _get_bot(db, bot_id)
+ if not set(scopes) <= VALID_SCOPES:
+ raise InvalidScopeError()
+
+ plaintext = generate_token()
+ token = ApiToken(owner_id=bot.id, token_hash=hash_token(plaintext), scopes=scopes)
+ db.add(token)
+ record_audit_log(
+ db, actor, "bot.issue_token", "user", bot.id, {"scopes": scopes}
+ )
+ await db.commit()
+ await db.refresh(token)
+ return token, plaintext
+
+
+async def list_api_tokens(db: AsyncSession, bot_id: uuid.UUID) -> list[ApiToken]:
+ result = await db.execute(
+ select(ApiToken).where(ApiToken.owner_id == bot_id).order_by(ApiToken.created_at)
+ )
+ return list(result.scalars().all())
+
+
+async def revoke_api_token(db: AsyncSession, actor: User, token_id: uuid.UUID) -> None:
+ token = await db.get(ApiToken, token_id)
+ if token is None:
+ raise TokenNotFoundError()
+ await db.delete(token)
+ record_audit_log(db, actor, "bot.revoke_token", "user", token.owner_id)
+ await db.commit()
+
+
+async def resolve_token(db: AsyncSession, plaintext: str) -> tuple[User, ApiToken] | None:
+ result = await db.execute(
+ select(ApiToken)
+ .where(ApiToken.token_hash == hash_token(plaintext))
+ .options(selectinload(ApiToken.owner))
+ )
+ token = result.scalar_one_or_none()
+ if token is None or not token.owner.is_active:
+ return None
+
+ token.last_used_at = datetime.now(timezone.utc)
+ await db.commit()
+ return token.owner, token
diff --git a/backend/app/services/message_events.py b/backend/app/services/message_events.py
new file mode 100644
index 0000000..425e364
--- /dev/null
+++ b/backend/app/services/message_events.py
@@ -0,0 +1,79 @@
+import uuid
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models import Message, Room, RoomMembership, User
+from app.services.push_service import send_push_to_user
+from app.services.webhook_service import dispatch_event
+from app.ws.broadcaster import RoomBroadcaster
+from app.ws.presence import Presence
+
+
+async def _notify_offline_members(
+ db: AsyncSession, presence: Presence, room_id: uuid.UUID, sender: User, content: str
+) -> None:
+ result = await db.execute(
+ select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
+ )
+ member_ids = {row[0] for row in result.all()}
+ # Subtract the sender explicitly rather than relying on them being
+ # "connected" (true for the WS path, since they just sent this over an
+ # active connection -- not true for the incoming-webhook REST path,
+ # which has no WS connection for the attributed sender at all).
+ offline_ids = member_ids - await presence.connected_user_ids(room_id) - {sender.id}
+ if not offline_ids:
+ return
+
+ room = await db.get(Room, room_id)
+ payload = {
+ "title": f"#{room.name}" if room else "New message",
+ "body": f"{sender.username}: {content}"[:120],
+ "room_id": str(room_id),
+ }
+ for user_id in offline_ids:
+ await send_push_to_user(db, user_id, payload)
+
+
+def _message_payload(message: Message, username: str) -> dict:
+ return {
+ "type": "message",
+ "id": str(message.id),
+ "room_id": str(message.room_id),
+ "user_id": str(message.user_id),
+ "username": username,
+ "content": message.content,
+ "created_at": message.created_at.isoformat(),
+ "edited_at": message.edited_at.isoformat() if message.edited_at else None,
+ }
+
+
+async def broadcast_new_message(
+ db: AsyncSession,
+ broadcaster: RoomBroadcaster,
+ presence: Presence,
+ room_id: uuid.UUID,
+ message: Message,
+ sender: User,
+) -> None:
+ """The full side-effect sequence for a newly created message, shared by
+ the WS "message" handler and the incoming-webhook receiver so both
+ trigger identical fan-out/push/event behavior."""
+ payload = _message_payload(message, sender.username)
+ await broadcaster.publish(room_id, payload)
+ await _notify_offline_members(db, presence, room_id, sender, message.content)
+ await dispatch_event(db, "message.created", room_id, payload)
+
+
+async def broadcast_message_update(
+ db: AsyncSession, broadcaster: RoomBroadcaster, room_id: uuid.UUID, message: Message
+) -> None:
+ payload = {
+ "type": "message_update",
+ "id": str(message.id),
+ "room_id": str(room_id),
+ "content": message.content,
+ "edited_at": message.edited_at.isoformat() if message.edited_at else None,
+ }
+ await broadcaster.publish(room_id, payload)
+ await dispatch_event(db, "message.updated", room_id, payload)
diff --git a/backend/app/services/message_service.py b/backend/app/services/message_service.py
index 97b4424..f033594 100644
--- a/backend/app/services/message_service.py
+++ b/backend/app/services/message_service.py
@@ -1,4 +1,5 @@
import uuid
+from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,6 +8,14 @@ from sqlalchemy.orm import selectinload
from app.models import Message
+class MessageNotFoundError(Exception):
+ pass
+
+
+class NotMessageAuthorError(Exception):
+ pass
+
+
async def create_message(
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID, content: str
) -> Message:
@@ -17,6 +26,22 @@ async def create_message(
return message
+async def edit_message(
+ db: AsyncSession, message_id: uuid.UUID, editor_id: uuid.UUID, content: str
+) -> Message:
+ message = await db.get(Message, message_id)
+ if message is None:
+ raise MessageNotFoundError()
+ if message.user_id != editor_id:
+ raise NotMessageAuthorError()
+
+ message.content = content
+ message.edited_at = datetime.now(timezone.utc)
+ await db.commit()
+ await db.refresh(message)
+ return message
+
+
async def list_recent_messages(
db: AsyncSession, room_id: uuid.UUID, limit: int = 50
) -> list[Message]:
diff --git a/backend/app/services/ssrf.py b/backend/app/services/ssrf.py
new file mode 100644
index 0000000..6b2eead
--- /dev/null
+++ b/backend/app/services/ssrf.py
@@ -0,0 +1,38 @@
+import ipaddress
+import socket
+from urllib.parse import urlparse
+
+
+class UnsafeWebhookUrlError(Exception):
+ pass
+
+
+def validate_target_url(url: str) -> None:
+ """Creation-time-only SSRF check: rejects non-http(s) schemes and any
+ target whose hostname resolves to a private/loopback/link-local/
+ reserved/multicast address. Not re-checked per delivery, so this doesn't
+ defend against DNS rebinding between creation and a later send -- a
+ documented known limitation, not an oversight.
+ """
+ parsed = urlparse(url)
+ if parsed.scheme not in ("http", "https"):
+ raise UnsafeWebhookUrlError()
+ if not parsed.hostname:
+ raise UnsafeWebhookUrlError()
+
+ try:
+ addrinfo = socket.getaddrinfo(parsed.hostname, None)
+ except socket.gaierror as exc:
+ raise UnsafeWebhookUrlError() from exc
+
+ for *_rest, sockaddr in addrinfo:
+ ip = ipaddress.ip_address(sockaddr[0])
+ if (
+ ip.is_private
+ or ip.is_loopback
+ or ip.is_link_local
+ or ip.is_reserved
+ or ip.is_multicast
+ or ip.is_unspecified
+ ):
+ raise UnsafeWebhookUrlError()
diff --git a/backend/app/services/webhook_delivery.py b/backend/app/services/webhook_delivery.py
new file mode 100644
index 0000000..925e4d3
--- /dev/null
+++ b/backend/app/services/webhook_delivery.py
@@ -0,0 +1,41 @@
+import hashlib
+import hmac
+import json
+import logging
+
+import httpx
+
+from app.models import EventSubscription
+
+logger = logging.getLogger(__name__)
+
+_TIMEOUT_SECONDS = 5.0
+
+
+async def deliver_event(subscription: EventSubscription, event_type: str, payload: dict) -> None:
+ """POST a signed event to a subscription's target_url.
+
+ Fire-once, best-effort: no retry/backoff, any failure is logged and
+ swallowed rather than raised -- a slow or dead third-party endpoint must
+ never affect message delivery to real room members. Safe to run in a
+ background asyncio.create_task (unlike the Phase 4 push lesson) because
+ there's no DB session involved here, just the already-serialized
+ payload and secret -- nothing that can outlive an event loop.
+ """
+ body = json.dumps({"event": event_type, "data": payload}).encode()
+ signature = hmac.new(subscription.signing_secret.encode(), body, hashlib.sha256).hexdigest()
+
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client:
+ await client.post(
+ subscription.target_url,
+ content=body,
+ headers={
+ "Content-Type": "application/json",
+ "X-KeepItTalking-Signature": f"sha256={signature}",
+ },
+ )
+ except httpx.HTTPError:
+ logger.warning(
+ "Failed to deliver %s event to subscription %s", event_type, subscription.id
+ )
diff --git a/backend/app/services/webhook_service.py b/backend/app/services/webhook_service.py
new file mode 100644
index 0000000..87c42bd
--- /dev/null
+++ b/backend/app/services/webhook_service.py
@@ -0,0 +1,153 @@
+import asyncio
+import uuid
+
+from sqlalchemy import or_, select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.models import EventSubscription, Message, Room, User, WebhookIncoming
+from app.security import generate_token
+from app.services.message_service import create_message
+from app.services.ssrf import validate_target_url
+from app.services.webhook_delivery import deliver_event
+
+VALID_EVENT_TYPES = {"message.created", "message.updated"}
+
+
+class WebhookNotFoundError(Exception):
+ pass
+
+
+class InvalidEventTypeError(Exception):
+ pass
+
+
+class SubscriptionNotFoundError(Exception):
+ pass
+
+
+async def create_incoming_webhook(
+ db: AsyncSession, actor: User, room_id: uuid.UUID, description: str | None
+) -> WebhookIncoming:
+ webhook = WebhookIncoming(
+ room_id=room_id, token=generate_token(), created_by=actor.id, description=description
+ )
+ db.add(webhook)
+ try:
+ await db.commit()
+ except IntegrityError:
+ # A token collision is astronomically unlikely (256 bits of
+ # randomness) -- surface it rather than silently masking it.
+ await db.rollback()
+ raise
+ await db.refresh(webhook)
+ return webhook
+
+
+async def list_incoming_webhooks(db: AsyncSession, room_id: uuid.UUID) -> list[WebhookIncoming]:
+ result = await db.execute(
+ select(WebhookIncoming)
+ .where(WebhookIncoming.room_id == room_id)
+ .order_by(WebhookIncoming.created_at)
+ )
+ return list(result.scalars().all())
+
+
+async def revoke_incoming_webhook(
+ db: AsyncSession, room_id: uuid.UUID, webhook_id: uuid.UUID
+) -> None:
+ webhook = await db.get(WebhookIncoming, webhook_id)
+ if webhook is None or webhook.room_id != room_id:
+ raise WebhookNotFoundError()
+ await db.delete(webhook)
+ await db.commit()
+
+
+async def list_all_incoming_webhooks_admin(db: AsyncSession) -> list[WebhookIncoming]:
+ result = await db.execute(
+ select(WebhookIncoming)
+ .options(selectinload(WebhookIncoming.room), selectinload(WebhookIncoming.creator))
+ .order_by(WebhookIncoming.created_at.desc())
+ )
+ return list(result.scalars().all())
+
+
+async def post_via_webhook(db: AsyncSession, token: str, content: str) -> tuple[Message, Room, User]:
+ result = await db.execute(
+ select(WebhookIncoming)
+ .where(WebhookIncoming.token == token)
+ .options(selectinload(WebhookIncoming.room), selectinload(WebhookIncoming.creator))
+ )
+ webhook = result.scalar_one_or_none()
+ if webhook is None:
+ raise WebhookNotFoundError()
+
+ message = await create_message(db, webhook.room_id, webhook.created_by, content)
+ return message, webhook.room, webhook.creator
+
+
+async def create_event_subscription(
+ db: AsyncSession,
+ actor: User,
+ room_id: uuid.UUID | None,
+ event_types: list[str],
+ target_url: str,
+) -> tuple[EventSubscription, str]:
+ if not set(event_types) <= VALID_EVENT_TYPES:
+ raise InvalidEventTypeError()
+ validate_target_url(target_url)
+
+ secret = generate_token()
+ subscription = EventSubscription(
+ room_id=room_id,
+ event_types=event_types,
+ target_url=target_url,
+ signing_secret=secret,
+ created_by=actor.id,
+ )
+ db.add(subscription)
+ await db.commit()
+ await db.refresh(subscription)
+ return subscription, secret
+
+
+async def list_event_subscriptions(db: AsyncSession, room_id: uuid.UUID) -> list[EventSubscription]:
+ result = await db.execute(
+ select(EventSubscription)
+ .where(EventSubscription.room_id == room_id)
+ .order_by(EventSubscription.created_at)
+ )
+ return list(result.scalars().all())
+
+
+async def revoke_event_subscription(
+ db: AsyncSession, room_id: uuid.UUID, subscription_id: uuid.UUID
+) -> None:
+ subscription = await db.get(EventSubscription, subscription_id)
+ if subscription is None or subscription.room_id != room_id:
+ raise SubscriptionNotFoundError()
+ await db.delete(subscription)
+ await db.commit()
+
+
+async def list_all_event_subscriptions_admin(db: AsyncSession) -> list[EventSubscription]:
+ result = await db.execute(
+ select(EventSubscription)
+ .options(selectinload(EventSubscription.room), selectinload(EventSubscription.creator))
+ .order_by(EventSubscription.created_at.desc())
+ )
+ return list(result.scalars().all())
+
+
+async def dispatch_event(
+ db: AsyncSession, event_type: str, room_id: uuid.UUID, payload: dict
+) -> None:
+ result = await db.execute(
+ select(EventSubscription).where(
+ or_(EventSubscription.room_id == room_id, EventSubscription.room_id.is_(None))
+ )
+ )
+ for subscription in result.scalars().all():
+ if event_type in subscription.event_types:
+ asyncio.create_task(deliver_event(subscription, event_type, payload))
diff --git a/backend/app/ws/chat.py b/backend/app/ws/chat.py
index 9d3beec..e4414fd 100644
--- a/backend/app/ws/chat.py
+++ b/backend/app/ws/chat.py
@@ -6,10 +6,15 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
-from app.models import Room, RoomMembership, User
-from app.services.message_service import create_message
-from app.services.push_service import send_push_to_user
-from app.ws.presence import Presence
+from app.models import ApiToken, RoomMembership, User
+from app.services.bot_service import resolve_token
+from app.services.message_events import broadcast_message_update, broadcast_new_message
+from app.services.message_service import (
+ MessageNotFoundError,
+ NotMessageAuthorError,
+ create_message,
+ edit_message,
+)
router = APIRouter(tags=["ws"])
@@ -20,6 +25,7 @@ class ClientEnvelope(BaseModel):
type: str
room_id: uuid.UUID | None = None
content: str | None = None
+ message_id: uuid.UUID | None = None
async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> bool:
@@ -31,46 +37,37 @@ 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 _notify_offline_members(
- db: AsyncSession,
- presence: Presence,
- room_id: uuid.UUID,
- sender: User,
- content: str,
-) -> None:
- result = await db.execute(
- select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
- )
- member_ids = {row[0] for row in result.all()}
- offline_ids = member_ids - await presence.connected_user_ids(room_id)
- if not offline_ids:
- return
-
- room = await db.get(Room, room_id)
- payload = {
- "title": f"#{room.name}" if room else "New message",
- "body": f"{sender.username}: {content}"[:120],
- "room_id": str(room_id),
- }
- for user_id in offline_ids:
- await send_push_to_user(db, user_id, payload)
+def _missing_scope(api_token: ApiToken | None, scope: str) -> bool:
+ return api_token is not None and scope not in api_token.scopes
@router.websocket("/ws/chat")
async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)) -> None:
- user_id_raw = websocket.session.get("user_id")
- if not user_id_raw:
- await websocket.close(code=WS_UNAUTHENTICATED)
- return
+ api_token: ApiToken | None = None
- user = await db.get(User, uuid.UUID(user_id_raw))
- if user is None:
- await websocket.close(code=WS_UNAUTHENTICATED)
- return
+ # Bots authenticate by setting Authorization on the WS handshake itself
+ # (not a browser cookie) -- same connection type/endpoint a human client
+ # uses, just a different credential.
+ auth_header = websocket.headers.get("authorization")
+ if auth_header and auth_header.lower().startswith("bearer "):
+ resolved = await resolve_token(db, auth_header[len("bearer ") :].strip())
+ if resolved is None:
+ await websocket.close(code=WS_UNAUTHENTICATED)
+ return
+ user, api_token = resolved
+ else:
+ user_id_raw = websocket.session.get("user_id")
+ if not user_id_raw:
+ await websocket.close(code=WS_UNAUTHENTICATED)
+ return
+ user = await db.get(User, uuid.UUID(user_id_raw))
+ if user is None or not user.is_active:
+ await websocket.close(code=WS_UNAUTHENTICATED)
+ return
await websocket.accept()
manager = websocket.app.state.connection_manager
- presence: Presence = websocket.app.state.presence
+ presence = websocket.app.state.presence
broadcaster = websocket.app.state.broadcaster
joined_rooms: set[uuid.UUID] = set()
@@ -111,6 +108,11 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
{"type": "error", "detail": "room_id and content required"}
)
continue
+ if _missing_scope(api_token, "write:messages"):
+ await websocket.send_json(
+ {"type": "error", "detail": "Token missing required scope: write:messages"}
+ )
+ continue
if envelope.room_id not in joined_rooms or not await _is_room_member(
db, envelope.room_id, user.id
):
@@ -119,21 +121,37 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
)
continue
message = await create_message(db, envelope.room_id, user.id, envelope.content)
- await broadcaster.publish(
- envelope.room_id,
- {
- "type": "message",
- "id": str(message.id),
- "room_id": str(message.room_id),
- "user_id": str(message.user_id),
- "username": user.username,
- "content": message.content,
- "created_at": message.created_at.isoformat(),
- },
- )
- await _notify_offline_members(
- db, presence, envelope.room_id, user, envelope.content
- )
+ await broadcast_new_message(db, broadcaster, presence, envelope.room_id, message, user)
+
+ elif envelope.type == "edit":
+ if envelope.room_id is None or envelope.message_id is None or not envelope.content:
+ await websocket.send_json(
+ {"type": "error", "detail": "room_id, message_id, and content required"}
+ )
+ continue
+ if _missing_scope(api_token, "write:messages"):
+ await websocket.send_json(
+ {"type": "error", "detail": "Token missing required scope: write:messages"}
+ )
+ continue
+ if envelope.room_id not in joined_rooms or not await _is_room_member(
+ db, envelope.room_id, user.id
+ ):
+ await websocket.send_json(
+ {"type": "error", "detail": "Not a member of this room"}
+ )
+ continue
+ try:
+ message = await edit_message(db, envelope.message_id, user.id, envelope.content)
+ except MessageNotFoundError:
+ await websocket.send_json({"type": "error", "detail": "Message not found"})
+ continue
+ except NotMessageAuthorError:
+ await websocket.send_json(
+ {"type": "error", "detail": "You can only edit your own messages"}
+ )
+ continue
+ await broadcast_message_update(db, broadcaster, envelope.room_id, message)
else:
await websocket.send_json(
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 95c954e..6c22478 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -16,6 +16,7 @@ dependencies = [
"itsdangerous>=2.2",
"pywebpush>=2.0",
"redis>=5.0",
+ "httpx>=0.27",
]
[project.scripts]
@@ -25,7 +26,6 @@ chatapp-create-user = "app.cli:main"
dev = [
"pytest>=8",
"pytest-asyncio>=0.24",
- "httpx>=0.27",
]
[build-system]
diff --git a/backend/tests/test_bots.py b/backend/tests/test_bots.py
new file mode 100644
index 0000000..7d56bd6
--- /dev/null
+++ b/backend/tests/test_bots.py
@@ -0,0 +1,209 @@
+import uuid
+
+from sqlalchemy import select
+
+from app.models import AdminAuditLog, User
+from tests.conftest import login_as, register_and_login
+
+
+async def _make_admin(db_session, user_id: str) -> None:
+ user = await db_session.get(User, uuid.UUID(user_id))
+ user.is_site_admin = True
+ await db_session.commit()
+
+
+async def test_create_bot_requires_site_admin(client, db_session):
+ await register_and_login(client, db_session, username="alice")
+ resp = await client.post("/api/admin/bots", json={"username": "helper-bot"})
+ assert resp.status_code == 403
+
+
+async def test_create_bot_and_issue_token(client, db_session):
+ admin = await register_and_login(client, db_session, username="admin1")
+ await _make_admin(db_session, admin["id"])
+
+ bot_resp = await client.post("/api/admin/bots", json={"username": "helper-bot"})
+ assert bot_resp.status_code == 201
+ bot = bot_resp.json()
+ assert bot["username"] == "helper-bot"
+ assert bot["is_active"] is True
+
+ token_resp = await client.post(
+ f"/api/admin/bots/{bot['id']}/tokens",
+ json={"scopes": ["read:messages", "write:messages"]},
+ )
+ assert token_resp.status_code == 201
+ token = token_resp.json()
+ assert token["token"].startswith("kit_")
+ assert token["scopes"] == ["read:messages", "write:messages"]
+
+ result = await db_session.execute(
+ select(AdminAuditLog).where(AdminAuditLog.target_id == uuid.UUID(bot["id"]))
+ )
+ actions = {e.action for e in result.scalars().all()}
+ assert actions == {"bot.create", "bot.issue_token"}
+
+
+async def test_create_token_rejects_unknown_scope(client, db_session):
+ admin = await register_and_login(client, db_session, username="admin1")
+ await _make_admin(db_session, admin["id"])
+ bot = (await client.post("/api/admin/bots", json={"username": "helper-bot"})).json()
+
+ resp = await client.post(
+ f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["delete:everything"]}
+ )
+ assert resp.status_code == 400
+
+
+async def test_list_bots_includes_created_bot(client, db_session):
+ admin = await register_and_login(client, db_session, username="admin1")
+ await _make_admin(db_session, admin["id"])
+ await client.post("/api/admin/bots", json={"username": "helper-bot"})
+
+ resp = await client.get("/api/admin/bots")
+ assert resp.status_code == 200
+ usernames = {b["username"] for b in resp.json()}
+ assert "helper-bot" in usernames
+
+
+async def test_revoke_token_invalidates_it(client, db_session):
+ admin = await register_and_login(client, db_session, username="admin1")
+ await _make_admin(db_session, admin["id"])
+ bot = (await client.post("/api/admin/bots", json={"username": "helper-bot"})).json()
+ token = (
+ await client.post(f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages"]})
+ ).json()
+
+ revoke_resp = await client.delete(f"/api/admin/bots/tokens/{token['id']}")
+ assert revoke_resp.status_code == 204
+
+ # A revoked token should no longer authenticate anything.
+ resp = await client.get(
+ "/api/rooms", headers={"Authorization": f"Bearer {token['token']}"}
+ )
+ assert resp.status_code == 401
+
+
+async def test_invalid_bearer_token_rejected(client):
+ resp = await client.get("/api/rooms", headers={"Authorization": "Bearer kit_not-a-real-token"})
+ assert resp.status_code == 401
+
+
+def _unique(prefix: str) -> str:
+ return f"{prefix}-{uuid.uuid4().hex[:8]}"
+
+
+def _register_ws(ws_client, username: str) -> dict:
+ from app.schemas.user import UserCreate
+ from app.services.auth_service import register_user
+
+ async def _seed():
+ async with ws_client.session_factory() as session:
+ await register_user(
+ session,
+ UserCreate(username=username, email=f"{username}@example.com", password="password123"),
+ )
+
+ ws_client.portal.call(_seed)
+ resp = ws_client.post(
+ "/api/auth/login", json={"username_or_email": username, "password": "password123"}
+ )
+ assert resp.status_code == 200, resp.text
+ return resp.json()
+
+
+def _make_admin_ws(ws_client, user_id: str) -> None:
+ async def _promote():
+ async with ws_client.session_factory() as session:
+ user = await session.get(User, uuid.UUID(user_id))
+ user.is_site_admin = True
+ await session.commit()
+
+ ws_client.portal.call(_promote)
+
+
+def test_bot_ws_message_with_write_scope_succeeds(ws_client_factory):
+ ws_client = ws_client_factory()
+ admin = _register_ws(ws_client, _unique("admin"))
+ _make_admin_ws(ws_client, admin["id"])
+
+ room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
+
+ bot = ws_client.post("/api/admin/bots", json={"username": _unique("bot")}).json()
+ token = ws_client.post(
+ f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages", "write:messages"]}
+ ).json()["token"]
+
+ # Bot joins the room via REST using its own bearer token -- exercises
+ # bearer-token auth on the plain REST path, not just WS.
+ join_resp = ws_client.post(
+ f"/api/rooms/{room['id']}/join", headers={"Authorization": f"Bearer {token}"}
+ )
+ assert join_resp.status_code == 200
+
+ with ws_client.websocket_connect(
+ "/ws/chat", headers={"Authorization": f"Bearer {token}"}
+ ) as ws:
+ ws.send_json({"type": "join", "room_id": room["id"]})
+ assert ws.receive_json()["type"] == "joined"
+ ws.send_json({"type": "message", "room_id": room["id"], "content": "hello from bot"})
+ message = ws.receive_json()
+ assert message["type"] == "message"
+ assert message["content"] == "hello from bot"
+ assert message["username"] == bot["username"]
+
+
+def test_bot_ws_message_without_write_scope_rejected(ws_client_factory):
+ ws_client = ws_client_factory()
+ admin = _register_ws(ws_client, _unique("admin"))
+ _make_admin_ws(ws_client, admin["id"])
+
+ room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
+
+ bot = ws_client.post("/api/admin/bots", json={"username": _unique("bot")}).json()
+ token = ws_client.post(
+ f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages"]}
+ ).json()["token"]
+
+ ws_client.post(f"/api/rooms/{room['id']}/join", headers={"Authorization": f"Bearer {token}"})
+
+ with ws_client.websocket_connect(
+ "/ws/chat", headers={"Authorization": f"Bearer {token}"}
+ ) as ws:
+ ws.send_json({"type": "join", "room_id": room["id"]})
+ assert ws.receive_json()["type"] == "joined"
+ ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
+ resp = ws.receive_json()
+ assert resp["type"] == "error"
+ assert "write:messages" in resp["detail"]
+
+
+def test_bot_rest_message_history_requires_read_scope(ws_client_factory):
+ ws_client = ws_client_factory()
+ admin = _register_ws(ws_client, _unique("admin"))
+ _make_admin_ws(ws_client, admin["id"])
+
+ room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
+
+ bot = ws_client.post("/api/admin/bots", json={"username": _unique("bot")}).json()
+ write_only_token = ws_client.post(
+ f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["write:messages"]}
+ ).json()["token"]
+ read_token = ws_client.post(
+ f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages"]}
+ ).json()["token"]
+
+ ws_client.post(
+ f"/api/rooms/{room['id']}/join", headers={"Authorization": f"Bearer {write_only_token}"}
+ )
+
+ no_scope_resp = ws_client.get(
+ f"/api/rooms/{room['id']}/messages",
+ headers={"Authorization": f"Bearer {write_only_token}"},
+ )
+ assert no_scope_resp.status_code == 403
+
+ with_scope_resp = ws_client.get(
+ f"/api/rooms/{room['id']}/messages", headers={"Authorization": f"Bearer {read_token}"}
+ )
+ assert with_scope_resp.status_code == 200
diff --git a/backend/tests/test_message_edit.py b/backend/tests/test_message_edit.py
new file mode 100644
index 0000000..49f06eb
--- /dev/null
+++ b/backend/tests/test_message_edit.py
@@ -0,0 +1,130 @@
+import uuid
+
+from app.schemas.user import UserCreate
+from app.services.auth_service import register_user
+
+
+def _unique(prefix: str) -> str:
+ return f"{prefix}-{uuid.uuid4().hex[:8]}"
+
+
+def _register_ws(ws_client, username: str) -> dict:
+ async def _seed():
+ async with ws_client.session_factory() as session:
+ await register_user(
+ session,
+ UserCreate(username=username, email=f"{username}@example.com", password="password123"),
+ )
+
+ ws_client.portal.call(_seed)
+ resp = ws_client.post(
+ "/api/auth/login", json={"username_or_email": username, "password": "password123"}
+ )
+ assert resp.status_code == 200, resp.text
+ return resp.json()
+
+
+def test_ws_edit_updates_content_and_broadcasts(ws_client):
+ username = _unique("alice")
+ _register_ws(ws_client, username=username)
+ room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
+
+ with ws_client.websocket_connect("/ws/chat") as ws:
+ ws.send_json({"type": "join", "room_id": room["id"]})
+ assert ws.receive_json()["type"] == "joined"
+
+ ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
+ message = ws.receive_json()
+ assert message["edited_at"] is None
+
+ ws.send_json(
+ {
+ "type": "edit",
+ "room_id": room["id"],
+ "message_id": message["id"],
+ "content": "hello, edited",
+ }
+ )
+ update = ws.receive_json()
+ assert update["type"] == "message_update"
+ assert update["id"] == message["id"]
+ assert update["content"] == "hello, edited"
+ assert update["edited_at"] is not None
+
+ resp = ws_client.get(f"/api/rooms/{room['id']}/messages")
+ history = resp.json()
+ edited = next(m for m in history if m["id"] == message["id"])
+ assert edited["content"] == "hello, edited"
+ assert edited["edited_at"] is not None
+
+
+def test_ws_edit_rejects_non_author(ws_client):
+ alice = _register_ws(ws_client, username=_unique("alice"))
+ room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
+
+ bob = _register_ws(ws_client, username=_unique("bob"))
+ ws_client.post(f"/api/rooms/{room['id']}/join")
+
+ ws_client.post(
+ "/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
+ )
+ with ws_client.websocket_connect("/ws/chat") as alice_ws:
+ alice_ws.send_json({"type": "join", "room_id": room["id"]})
+ assert alice_ws.receive_json()["type"] == "joined"
+ alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
+ message = alice_ws.receive_json()
+
+ ws_client.post(
+ "/api/auth/login",
+ json={"username_or_email": bob["username"], "password": "password123"},
+ )
+ with ws_client.websocket_connect("/ws/chat") as bob_ws:
+ bob_ws.send_json({"type": "join", "room_id": room["id"]})
+ assert bob_ws.receive_json()["type"] == "joined"
+ bob_ws.send_json(
+ {
+ "type": "edit",
+ "room_id": room["id"],
+ "message_id": message["id"],
+ "content": "hacked",
+ }
+ )
+ resp = bob_ws.receive_json()
+ assert resp["type"] == "error"
+ assert "own messages" in resp["detail"]
+
+
+def test_edit_fans_out_across_instances(ws_client_factory):
+ instance1 = ws_client_factory()
+ instance2 = ws_client_factory()
+
+ alice = _register_ws(instance1, _unique("alice"))
+ room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
+
+ bob = _register_ws(instance2, _unique("bob"))
+ instance2.post(f"/api/rooms/{room['id']}/join")
+
+ with instance2.websocket_connect("/ws/chat") as bob_ws:
+ bob_ws.send_json({"type": "join", "room_id": room["id"]})
+ assert bob_ws.receive_json()["type"] == "joined"
+
+ with instance1.websocket_connect("/ws/chat") as alice_ws:
+ alice_ws.send_json({"type": "join", "room_id": room["id"]})
+ assert alice_ws.receive_json()["type"] == "joined"
+ alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
+ message = alice_ws.receive_json()
+ assert bob_ws.receive_json()["type"] == "message"
+
+ alice_ws.send_json(
+ {
+ "type": "edit",
+ "room_id": room["id"],
+ "message_id": message["id"],
+ "content": "hi, edited",
+ }
+ )
+ assert alice_ws.receive_json()["type"] == "message_update"
+
+ update = bob_ws.receive_json()
+ assert update["type"] == "message_update"
+ assert update["content"] == "hi, edited"
diff --git a/backend/tests/test_webhooks.py b/backend/tests/test_webhooks.py
new file mode 100644
index 0000000..466eb8c
--- /dev/null
+++ b/backend/tests/test_webhooks.py
@@ -0,0 +1,164 @@
+import asyncio
+import hashlib
+import hmac
+import json
+import uuid
+
+import pytest
+
+from app.models import PushSubscription
+from app.schemas.user import UserCreate
+from app.services.auth_service import register_user
+from app.services.room_service import join_room
+from app.services.ssrf import UnsafeWebhookUrlError, validate_target_url
+from tests.conftest import register_and_login
+
+
+def test_validate_target_url_rejects_loopback():
+ with pytest.raises(UnsafeWebhookUrlError):
+ validate_target_url("http://127.0.0.1/hook")
+
+
+def test_validate_target_url_rejects_private_range():
+ with pytest.raises(UnsafeWebhookUrlError):
+ validate_target_url("http://10.0.0.5/hook")
+
+
+def test_validate_target_url_rejects_non_http_scheme():
+ with pytest.raises(UnsafeWebhookUrlError):
+ validate_target_url("ftp://8.8.8.8/hook")
+
+
+def test_validate_target_url_accepts_public_address():
+ # 8.8.8.8 is a stable, well-known public IP (Google's public DNS
+ # resolver) -- a literal IP so this resolves without any real network
+ # access (getaddrinfo parses a literal IP without touching DNS/the
+ # network), and it isn't flagged by any of ipaddress's private/
+ # reserved/loopback/etc checks, so it exercises the "allowed" path.
+ # (203.0.113.0/24, the usual RFC 5737 documentation-only choice, is
+ # actually flagged is_private by Python's ipaddress module -- not
+ # usable here.)
+ validate_target_url("http://8.8.8.8/hook")
+
+
+async def test_event_subscription_rejects_private_target(client, db_session):
+ await register_and_login(client, db_session, username="alice")
+ room = (await client.post("/api/rooms", json={"name": "general"})).json()
+
+ resp = await client.post(
+ f"/api/rooms/{room['id']}/event-subscriptions",
+ json={"event_types": ["message.created"], "target_url": "http://127.0.0.1/hook"},
+ )
+ assert resp.status_code == 400
+
+
+async def test_incoming_webhook_unknown_token_404s(client, db_session):
+ await register_and_login(client, db_session, username="alice")
+ resp = await client.post("/api/webhooks/incoming/not-a-real-token", json={"content": "hi"})
+ assert resp.status_code == 404
+
+
+async def test_incoming_webhook_posts_message_and_pushes_offline_members(
+ client, db_session, monkeypatch
+):
+ calls = []
+ monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
+
+ alice = await register_and_login(client, db_session, username="alice")
+ room = (await client.post("/api/rooms", json={"name": "general"})).json()
+
+ webhook_resp = await client.post(
+ f"/api/rooms/{room['id']}/webhooks/incoming", json={"description": "CI bot"}
+ )
+ assert webhook_resp.status_code == 201
+ webhook = webhook_resp.json()
+ assert webhook["token"]
+
+ bob = await register_user(
+ db_session, UserCreate(username="bob", email="bob@example.com", password="password123")
+ )
+ await join_room(db_session, uuid.UUID(room["id"]), bob.id)
+ db_session.add(
+ PushSubscription(
+ user_id=bob.id,
+ endpoint="https://push.example.com/bob",
+ p256dh_key="p256dh",
+ auth_key="auth",
+ )
+ )
+ await db_session.commit()
+
+ post_resp = await client.post(
+ f"/api/webhooks/incoming/{webhook['token']}", json={"content": "deploy succeeded"}
+ )
+ assert post_resp.status_code == 204
+
+ history = (await client.get(f"/api/rooms/{room['id']}/messages")).json()
+ assert any(m["content"] == "deploy succeeded" for m in history)
+
+ # Exactly one push -- to bob. If the sender (webhook creator, alice) were
+ # incorrectly included in "offline members" (no WS connection exists for
+ # either party in this REST-only test), this would be 2.
+ assert len(calls) == 1
+ assert "deploy succeeded" in calls[0]["data"]
+
+
+async def test_outgoing_webhook_delivers_signed_payload(client, db_session, monkeypatch):
+ captured_tasks: list[asyncio.Task] = []
+ real_create_task = asyncio.create_task
+
+ def fake_create_task(coro):
+ task = real_create_task(coro)
+ captured_tasks.append(task)
+ return task
+
+ monkeypatch.setattr("app.services.webhook_service.asyncio.create_task", fake_create_task)
+
+ posts = []
+
+ class FakeResponse:
+ status_code = 200
+
+ class FakeAsyncClient:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ return False
+
+ async def post(self, url, content=None, headers=None):
+ posts.append({"url": url, "content": content, "headers": headers})
+ return FakeResponse()
+
+ monkeypatch.setattr("app.services.webhook_delivery.httpx.AsyncClient", FakeAsyncClient)
+
+ await register_and_login(client, db_session, username="alice")
+ room = (await client.post("/api/rooms", json={"name": "general"})).json()
+
+ sub_resp = await client.post(
+ f"/api/rooms/{room['id']}/event-subscriptions",
+ json={"event_types": ["message.created"], "target_url": "http://8.8.8.8/hook"},
+ )
+ assert sub_resp.status_code == 201
+ secret = sub_resp.json()["signing_secret"]
+
+ webhook = (
+ await client.post(f"/api/rooms/{room['id']}/webhooks/incoming", json={})
+ ).json()
+ resp = await client.post(
+ f"/api/webhooks/incoming/{webhook['token']}", json={"content": "ping"}
+ )
+ assert resp.status_code == 204
+
+ await asyncio.gather(*captured_tasks)
+
+ assert len(posts) == 1
+ body = posts[0]["content"]
+ expected_signature = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
+ assert posts[0]["headers"]["X-KeepItTalking-Signature"] == f"sha256={expected_signature}"
+ payload = json.loads(body)
+ assert payload["event"] == "message.created"
+ assert payload["data"]["content"] == "ping"
diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts
index c204ee7..663257c 100644
--- a/frontend/src/api/admin.ts
+++ b/frontend/src/api/admin.ts
@@ -1,5 +1,11 @@
import { apiFetch } from './client'
-import type { AdminRoom, AdminUser, AuditLogEntry } from '../types'
+import type {
+ AdminRoom,
+ AdminUser,
+ AuditLogEntry,
+ EventSubscriptionAdmin,
+ WebhookIncomingAdmin,
+} from '../types'
export function listAdminUsers(): Promise
{integrationsError}
} + +{`${window.location.origin}/api/webhooks/incoming/${w.token}`}
+ {w.description && {w.description}}
+ {s.target_url}
+ {s.event_types.join(', ')}
+
+ New signing secret (copy it now, it won't be shown again):{' '}
+ {newSigningSecret}
+