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
+105 -23
View File
@@ -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)
@@ -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 ###
+21
View File
@@ -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:
+16 -7
View File
@@ -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
+6
View File
@@ -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",
]
+23
View File
@@ -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")
+25
View File
@@ -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")
+25
View File
@@ -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")
+50 -1
View File
@@ -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
]
+91
View File
@@ -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")
+119 -2
View File
@@ -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")
+28
View File
@@ -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)
+35
View File
@@ -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
+1
View File
@@ -13,3 +13,4 @@ class MessageRead(BaseModel):
username: str
content: str
created_at: datetime
edited_at: datetime | None
+53
View File
@@ -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
+16
View File
@@ -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()
+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())
+39
View File
@@ -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())
+119
View File
@@ -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
+79
View File
@@ -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)
+25
View File
@@ -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]:
+38
View File
@@ -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()
+41
View File
@@ -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
)
+153
View File
@@ -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))
+69 -51
View File
@@ -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(
+1 -1
View File
@@ -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]
+209
View File
@@ -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
+130
View File
@@ -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"
+164
View File
@@ -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"
+15 -1
View File
@@ -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<AdminUser[]> {
return apiFetch<AdminUser[]>('/api/admin/users')
@@ -50,3 +56,11 @@ export function transferOwnershipAdmin(roomId: string, newOwnerId: string): Prom
export function listAuditLog(limit = 50, offset = 0): Promise<AuditLogEntry[]> {
return apiFetch<AuditLogEntry[]>(`/api/admin/audit-log?limit=${limit}&offset=${offset}`)
}
export function listAllIncomingWebhooks(): Promise<WebhookIncomingAdmin[]> {
return apiFetch<WebhookIncomingAdmin[]>('/api/admin/webhooks/incoming')
}
export function listAllEventSubscriptions(): Promise<EventSubscriptionAdmin[]> {
return apiFetch<EventSubscriptionAdmin[]>('/api/admin/event-subscriptions')
}
+28
View File
@@ -0,0 +1,28 @@
import { apiFetch } from './client'
import type { ApiScope, ApiToken, ApiTokenCreated, Bot } from '../types'
export function createBot(username: string): Promise<Bot> {
return apiFetch<Bot>('/api/admin/bots', {
method: 'POST',
body: JSON.stringify({ username }),
})
}
export function listBots(): Promise<Bot[]> {
return apiFetch<Bot[]>('/api/admin/bots')
}
export function createApiToken(botId: string, scopes: ApiScope[]): Promise<ApiTokenCreated> {
return apiFetch<ApiTokenCreated>(`/api/admin/bots/${botId}/tokens`, {
method: 'POST',
body: JSON.stringify({ scopes }),
})
}
export function listApiTokens(botId: string): Promise<ApiToken[]> {
return apiFetch<ApiToken[]>(`/api/admin/bots/${botId}/tokens`)
}
export function revokeApiToken(tokenId: string): Promise<void> {
return apiFetch<void>(`/api/admin/bots/tokens/${tokenId}`, { method: 'DELETE' })
}
+48
View File
@@ -0,0 +1,48 @@
import { apiFetch } from './client'
import type {
EventSubscription,
EventSubscriptionCreated,
EventType,
WebhookIncoming,
} from '../types'
export function createIncomingWebhook(
roomId: string,
description?: string,
): Promise<WebhookIncoming> {
return apiFetch<WebhookIncoming>(`/api/rooms/${roomId}/webhooks/incoming`, {
method: 'POST',
body: JSON.stringify({ description: description || null }),
})
}
export function listIncomingWebhooks(roomId: string): Promise<WebhookIncoming[]> {
return apiFetch<WebhookIncoming[]>(`/api/rooms/${roomId}/webhooks/incoming`)
}
export function revokeIncomingWebhook(roomId: string, webhookId: string): Promise<void> {
return apiFetch<void>(`/api/rooms/${roomId}/webhooks/incoming/${webhookId}`, {
method: 'DELETE',
})
}
export function createEventSubscription(
roomId: string,
eventTypes: EventType[],
targetUrl: string,
): Promise<EventSubscriptionCreated> {
return apiFetch<EventSubscriptionCreated>(`/api/rooms/${roomId}/event-subscriptions`, {
method: 'POST',
body: JSON.stringify({ event_types: eventTypes, target_url: targetUrl }),
})
}
export function listEventSubscriptions(roomId: string): Promise<EventSubscription[]> {
return apiFetch<EventSubscription[]>(`/api/rooms/${roomId}/event-subscriptions`)
}
export function revokeEventSubscription(roomId: string, subscriptionId: string): Promise<void> {
return apiFetch<void>(`/api/rooms/${roomId}/event-subscriptions/${subscriptionId}`, {
method: 'DELETE',
})
}
+13 -2
View File
@@ -43,6 +43,17 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
const onMessage = useCallback((envelope: ServerEnvelope) => {
if (envelope.type === 'message') {
setLive((prev) => [...prev, envelope])
} else if (envelope.type === 'message_update') {
setHistory((prev) =>
prev.map((m) =>
m.id === envelope.id ? { ...m, content: envelope.content, edited_at: envelope.edited_at } : m,
),
)
setLive((prev) =>
prev.map((m) =>
m.id === envelope.id ? { ...m, content: envelope.content, edited_at: envelope.edited_at } : m,
),
)
} else if (envelope.type === 'error') {
setWsError(envelope.detail)
}
@@ -50,7 +61,7 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
const onUnauthenticated = useCallback(() => navigate('/login'), [navigate])
const { connected, send } = useChatSocket({ roomId: room.id, onMessage, onUnauthenticated })
const { connected, send, sendEdit } = useChatSocket({ roomId: room.id, onMessage, onUnauthenticated })
return (
<section className="chat-pane">
@@ -88,7 +99,7 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
</p>
)}
<MessageList messages={[...history, ...live]} members={members} />
<MessageList messages={[...history, ...live]} members={members} onEdit={sendEdit} />
<Composer roomName={room.name} disabled={!connected} onSend={send} />
</section>
)
+37
View File
@@ -42,6 +42,7 @@
}
.message-bubble {
position: relative;
background: var(--ds-surface-2);
color: var(--ds-text);
padding: 8px 12px;
@@ -56,9 +57,45 @@
background: var(--ds-accent-2);
}
.message-edit-link {
display: none;
position: absolute;
top: -18px;
right: 0;
background: transparent;
border: none;
color: var(--ds-muted);
font-size: 0.68rem;
cursor: pointer;
padding: 2px 4px;
}
.message-edit-link:hover {
color: var(--ds-text);
}
.message-bubble:hover .message-edit-link {
display: inline;
}
.message-edit-input {
background: var(--ds-surface-2);
border: 1px solid var(--ds-accent);
border-radius: 10px;
padding: 8px 12px;
font-size: 0.88rem;
color: var(--ds-text);
font-family: var(--sans);
width: 100%;
}
.message-time {
font-size: 0.68rem;
color: var(--ds-muted);
margin-top: 3px;
font-family: var(--mono);
}
.message-edited {
font-style: italic;
}
+45 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useAuth } from '../context/AuthContext'
import { senderColorIndex } from '../lib/messageGrouping'
import type { ChatMessageEnvelope, Message, RoomMember } from '../types'
@@ -8,16 +8,30 @@ import './MessageList.css'
interface MessageListProps {
messages: (Message | ChatMessageEnvelope)[]
members: RoomMember[]
onEdit: (messageId: string, content: string) => void
}
export function MessageList({ messages, members }: MessageListProps) {
export function MessageList({ messages, members, onEdit }: MessageListProps) {
const { user } = useAuth()
const bottomRef = useRef<HTMLDivElement>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [draft, setDraft] = useState('')
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: 'end' })
}, [messages.length])
function startEdit(msg: Message | ChatMessageEnvelope) {
setEditingId(msg.id)
setDraft(msg.content)
}
function commitEdit(messageId: string) {
const trimmed = draft.trim()
if (trimmed) onEdit(messageId, trimmed)
setEditingId(null)
}
return (
<div className="message-list">
{messages.map((msg, i) => {
@@ -25,6 +39,7 @@ export function MessageList({ messages, members }: MessageListProps) {
const prev = messages[i - 1]
const showAvatar = !mine && (!prev || prev.user_id !== msg.user_id)
const showName = showAvatar
const editing = editingId === msg.id
return (
<div key={msg.id} className={`message-row${mine ? ' message-row-mine' : ''}`}>
@@ -37,9 +52,36 @@ export function MessageList({ messages, members }: MessageListProps) {
)}
<div className="message-bubble-wrap">
{showName && <div className="message-author">{msg.username}</div>}
<div className={`message-bubble${mine ? ' message-bubble-mine' : ''}`}>{msg.content}</div>
{editing ? (
<input
autoFocus
className="message-edit-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commitEdit(msg.id)
if (e.key === 'Escape') setEditingId(null)
}}
onBlur={() => commitEdit(msg.id)}
/>
) : (
<div className={`message-bubble${mine ? ' message-bubble-mine' : ''}`}>
{msg.content}
{mine && (
<button
type="button"
className="message-edit-link"
onClick={() => startEdit(msg)}
aria-label="Edit message"
>
Edit
</button>
)}
</div>
)}
<div className="message-time">
{new Date(msg.created_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
{msg.edited_at && <span className="message-edited"> (edited)</span>}
</div>
</div>
</div>
+84
View File
@@ -206,6 +206,90 @@
resize: vertical;
}
.room-info-integrations {
display: flex;
flex-direction: column;
gap: var(--sp-4);
margin-top: var(--sp-2);
}
.room-info-integration-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.room-info-webhook-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-2);
font-size: 0.78rem;
}
.room-info-webhook-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.room-info-webhook-info code {
background: var(--ds-surface-2);
padding: 3px 6px;
border-radius: 6px;
font-size: 0.72rem;
word-break: break-all;
}
.room-info-webhook-desc {
color: var(--ds-muted);
font-size: 0.74rem;
}
.room-info-subscription-form {
display: flex;
flex-direction: column;
gap: 6px;
}
.room-info-subscription-form input {
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
padding: 7px 10px;
color: var(--ds-text);
font-size: 0.82rem;
}
.room-info-event-types {
display: flex;
gap: var(--sp-3);
flex-wrap: wrap;
}
.room-info-event-type-checkbox {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.74rem;
font-family: var(--mono);
color: var(--ds-muted);
}
.room-info-signing-secret {
font-size: 0.76rem;
color: var(--ds-accent);
margin: 0;
}
.room-info-signing-secret code {
background: var(--ds-surface-2);
padding: 3px 6px;
border-radius: 6px;
word-break: break-all;
}
.room-info-danger-link {
background: transparent;
border: none;
+175 -1
View File
@@ -9,12 +9,30 @@ import {
transferOwnership,
updateRoom,
} from '../api/rooms'
import {
createEventSubscription,
createIncomingWebhook,
listEventSubscriptions,
listIncomingWebhooks,
revokeEventSubscription,
revokeIncomingWebhook,
} from '../api/webhooks'
import { useAuth } from '../context/AuthContext'
import type { Invite, MyRoomItem, RoomMember, RoomRole } from '../types'
import type {
EventSubscription,
EventType,
Invite,
MyRoomItem,
RoomMember,
RoomRole,
WebhookIncoming,
} from '../types'
import { RoomAvatar } from './RoomAvatar'
import { UserAvatar } from './UserAvatar'
import './RoomInfoPanel.css'
const EVENT_TYPES: EventType[] = ['message.created', 'message.updated']
interface RoomInfoPanelProps {
room: MyRoomItem
members: RoomMember[]
@@ -46,6 +64,15 @@ export function RoomInfoPanel({
const [descDraft, setDescDraft] = useState(room.description ?? '')
const [roomError, setRoomError] = useState<string | null>(null)
const [integrationsOpen, setIntegrationsOpen] = useState(false)
const [incomingWebhooks, setIncomingWebhooks] = useState<WebhookIncoming[]>([])
const [webhookDescription, setWebhookDescription] = useState('')
const [eventSubscriptions, setEventSubscriptions] = useState<EventSubscription[]>([])
const [targetUrl, setTargetUrl] = useState('')
const [selectedEventTypes, setSelectedEventTypes] = useState<EventType[]>([])
const [newSigningSecret, setNewSigningSecret] = useState<string | null>(null)
const [integrationsError, setIntegrationsError] = useState<string | null>(null)
const canManage = myRole === 'admin' || myRole === 'owner'
useEffect(() => {
@@ -53,8 +80,12 @@ export function RoomInfoPanel({
setDescDraft(room.description ?? '')
if (canManage) {
listRoomInvites(room.id).then(setPendingInvites).catch(() => setPendingInvites([]))
listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([]))
listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([]))
} else {
setPendingInvites([])
setIncomingWebhooks([])
setEventSubscriptions([])
}
}, [room.id, room.name, room.description, canManage])
@@ -77,6 +108,50 @@ export function RoomInfoPanel({
setPendingInvites(await listRoomInvites(room.id))
}
async function handleCreateWebhook(e: FormEvent) {
e.preventDefault()
setIntegrationsError(null)
try {
await createIncomingWebhook(room.id, webhookDescription.trim())
setWebhookDescription('')
setIncomingWebhooks(await listIncomingWebhooks(room.id))
} catch (err) {
setIntegrationsError(err instanceof ApiError ? err.message : String(err))
}
}
async function handleRevokeWebhook(webhookId: string) {
await revokeIncomingWebhook(room.id, webhookId)
setIncomingWebhooks(await listIncomingWebhooks(room.id))
}
function toggleEventType(eventType: EventType) {
setSelectedEventTypes((prev) =>
prev.includes(eventType) ? prev.filter((t) => t !== eventType) : [...prev, eventType],
)
}
async function handleCreateSubscription(e: FormEvent) {
e.preventDefault()
setIntegrationsError(null)
const url = targetUrl.trim()
if (!url || selectedEventTypes.length === 0) return
try {
const created = await createEventSubscription(room.id, selectedEventTypes, url)
setNewSigningSecret(created.signing_secret)
setTargetUrl('')
setSelectedEventTypes([])
setEventSubscriptions(await listEventSubscriptions(room.id))
} catch (err) {
setIntegrationsError(err instanceof ApiError ? err.message : String(err))
}
}
async function handleRevokeSubscription(subscriptionId: string) {
await revokeEventSubscription(room.id, subscriptionId)
setEventSubscriptions(await listEventSubscriptions(room.id))
}
async function handleRemove(userId: string) {
setBusyUserId(userId)
try {
@@ -252,6 +327,105 @@ export function RoomInfoPanel({
</div>
)}
{canManage && (
<div className="room-info-section">
<button
type="button"
className="room-info-settings-toggle"
onClick={() => setIntegrationsOpen((v) => !v)}
>
Integrations {integrationsOpen ? '' : '+'}
</button>
{integrationsOpen && (
<div className="room-info-integrations">
{integrationsError && <p className="room-info-error">{integrationsError}</p>}
<div className="room-info-integration-group">
<div className="room-info-label">Incoming webhooks</div>
{incomingWebhooks.map((w) => (
<div key={w.id} className="room-info-webhook-row">
<div className="room-info-webhook-info">
<code>{`${window.location.origin}/api/webhooks/incoming/${w.token}`}</code>
{w.description && <span className="room-info-webhook-desc">{w.description}</span>}
</div>
<button
type="button"
className="room-info-danger-link"
onClick={() => handleRevokeWebhook(w.id)}
>
Revoke
</button>
</div>
))}
<form className="room-info-invite-form" onSubmit={handleCreateWebhook}>
<input
type="text"
placeholder="Description (optional)"
value={webhookDescription}
onChange={(e) => setWebhookDescription(e.target.value)}
/>
<button type="submit" className="btn-secondary">
Add
</button>
</form>
</div>
<div className="room-info-integration-group">
<div className="room-info-label">Outgoing event subscriptions</div>
{eventSubscriptions.map((s) => (
<div key={s.id} className="room-info-webhook-row">
<div className="room-info-webhook-info">
<code>{s.target_url}</code>
<span className="room-info-webhook-desc">{s.event_types.join(', ')}</span>
</div>
<button
type="button"
className="room-info-danger-link"
onClick={() => handleRevokeSubscription(s.id)}
>
Revoke
</button>
</div>
))}
<form className="room-info-subscription-form" onSubmit={handleCreateSubscription}>
<input
type="text"
placeholder="https://example.com/hook"
value={targetUrl}
onChange={(e) => setTargetUrl(e.target.value)}
/>
<div className="room-info-event-types">
{EVENT_TYPES.map((eventType) => (
<label key={eventType} className="room-info-event-type-checkbox">
<input
type="checkbox"
checked={selectedEventTypes.includes(eventType)}
onChange={() => toggleEventType(eventType)}
/>
{eventType}
</label>
))}
</div>
<button
type="submit"
className="btn-secondary"
disabled={!targetUrl.trim() || selectedEventTypes.length === 0}
>
Add
</button>
</form>
{newSigningSecret && (
<p className="room-info-signing-secret">
New signing secret (copy it now, it won't be shown again):{' '}
<code>{newSigningSecret}</code>
</p>
)}
</div>
</div>
)}
</div>
)}
{myRole === 'owner' && (
<div className="room-info-section">
<button
+87
View File
@@ -141,3 +141,90 @@
.admin-load-more {
margin-top: var(--sp-4);
}
.admin-create-form {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-4);
}
.admin-create-form input {
flex: 1;
max-width: 280px;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
padding: 7px 10px;
color: var(--ds-text);
font-size: 0.84rem;
}
.admin-bot-detail {
background: var(--ds-void-2);
}
.admin-token-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: var(--sp-3);
}
.admin-token-row {
display: flex;
align-items: center;
gap: var(--sp-3);
font-size: 0.8rem;
}
.admin-token-scopes {
font-family: var(--mono);
color: var(--ds-text);
}
.admin-token-meta {
color: var(--ds-muted);
flex: 1;
}
.admin-token-revoke {
background: transparent;
border: none;
color: var(--ds-danger);
font-size: 0.76rem;
cursor: pointer;
}
.admin-issue-token {
display: flex;
align-items: center;
gap: var(--sp-3);
flex-wrap: wrap;
}
.admin-scope-checkbox {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.78rem;
font-family: var(--mono);
color: var(--ds-muted);
}
.admin-new-token {
margin-top: var(--sp-3);
font-size: 0.8rem;
color: var(--ds-accent);
}
.admin-new-token code {
background: var(--ds-surface-2);
padding: 3px 8px;
border-radius: 6px;
word-break: break-all;
}
.admin-subheading {
font-size: 0.95rem;
margin: var(--sp-6) 0 var(--sp-3);
}
+234 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { Fragment, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import {
archiveRoom,
@@ -6,6 +6,8 @@ import {
demoteUser,
listAdminRooms,
listAdminUsers,
listAllEventSubscriptions,
listAllIncomingWebhooks,
listAuditLog,
promoteUser,
reactivateUser,
@@ -14,14 +16,25 @@ import {
unarchiveRoom,
} from '../api/admin'
import { ApiError } from '../api/client'
import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots'
import { useAuth } from '../context/AuthContext'
import type { AdminRoom, AdminUser, AuditLogEntry } from '../types'
import type {
AdminRoom,
AdminUser,
ApiScope,
ApiToken,
AuditLogEntry,
Bot,
EventSubscriptionAdmin,
WebhookIncomingAdmin,
} from '../types'
import { TopBar } from '../components/TopBar'
import './AdminPage.css'
type Tab = 'users' | 'rooms' | 'audit' | 'settings'
type Tab = 'users' | 'rooms' | 'bots' | 'audit' | 'settings'
const AUDIT_PAGE_SIZE = 50
const ALL_SCOPES: ApiScope[] = ['read:messages', 'write:messages', 'manage:rooms']
export function AdminPage() {
const { user: currentUser } = useAuth()
@@ -33,6 +46,15 @@ export function AdminPage() {
const [busyId, setBusyId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [bots, setBots] = useState<Bot[]>([])
const [newBotUsername, setNewBotUsername] = useState('')
const [expandedBotId, setExpandedBotId] = useState<string | null>(null)
const [tokensByBot, setTokensByBot] = useState<Record<string, ApiToken[]>>({})
const [newTokenScopes, setNewTokenScopes] = useState<ApiScope[]>([])
const [justCreatedToken, setJustCreatedToken] = useState<string | null>(null)
const [incomingWebhooks, setIncomingWebhooks] = useState<WebhookIncomingAdmin[]>([])
const [eventSubscriptions, setEventSubscriptions] = useState<EventSubscriptionAdmin[]>([])
function reportError(err: unknown) {
setError(err instanceof ApiError ? err.message : String(err))
}
@@ -54,12 +76,25 @@ export function AdminPage() {
.catch(reportError)
}
function loadBots() {
listBots().then(setBots).catch(reportError)
}
function loadWebhooksAdmin() {
listAllIncomingWebhooks().then(setIncomingWebhooks).catch(reportError)
listAllEventSubscriptions().then(setEventSubscriptions).catch(reportError)
}
useEffect(() => {
if (tab === 'users') loadUsers()
if (tab === 'rooms') {
loadRooms()
if (users.length === 0) loadUsers() // needed to resolve usernames for ownership transfer
}
if (tab === 'bots') {
loadBots()
loadWebhooksAdmin()
}
if (tab === 'audit') loadAuditLog()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab])
@@ -119,6 +154,59 @@ export function AdminPage() {
})
}
async function handleCreateBot() {
const username = newBotUsername.trim()
if (!username) return
setError(null)
try {
await createBot(username)
setNewBotUsername('')
loadBots()
} catch (err) {
reportError(err)
}
}
function toggleExpandBot(botId: string) {
if (expandedBotId === botId) {
setExpandedBotId(null)
return
}
setExpandedBotId(botId)
setNewTokenScopes([])
setJustCreatedToken(null)
if (!tokensByBot[botId]) {
listApiTokens(botId)
.then((tokens) => setTokensByBot((prev) => ({ ...prev, [botId]: tokens })))
.catch(reportError)
}
}
function toggleScope(scope: ApiScope) {
setNewTokenScopes((prev) =>
prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope],
)
}
async function handleIssueToken(botId: string) {
if (newTokenScopes.length === 0) return
await withBusy(botId, async () => {
const created = await createApiToken(botId, newTokenScopes)
setJustCreatedToken(created.token)
setNewTokenScopes([])
const tokens = await listApiTokens(botId)
setTokensByBot((prev) => ({ ...prev, [botId]: tokens }))
})
}
async function handleRevokeToken(botId: string, tokenId: string) {
await withBusy(tokenId, async () => {
await revokeApiToken(tokenId)
const tokens = await listApiTokens(botId)
setTokensByBot((prev) => ({ ...prev, [botId]: tokens }))
})
}
return (
<div className="admin-page">
<TopBar />
@@ -131,7 +219,7 @@ export function AdminPage() {
</div>
<div className="admin-tabs" role="tablist">
{(['users', 'rooms', 'audit', 'settings'] as const).map((t) => (
{(['users', 'rooms', 'bots', 'audit', 'settings'] as const).map((t) => (
<button
key={t}
type="button"
@@ -142,6 +230,7 @@ export function AdminPage() {
>
{t === 'users' && 'Users'}
{t === 'rooms' && 'Rooms'}
{t === 'bots' && 'Bots'}
{t === 'audit' && 'Audit log'}
{t === 'settings' && 'Settings'}
</button>
@@ -237,6 +326,147 @@ export function AdminPage() {
</table>
)}
{tab === 'bots' && (
<>
<div className="admin-create-form">
<input
type="text"
placeholder="Bot username"
value={newBotUsername}
onChange={(e) => setNewBotUsername(e.target.value)}
/>
<button type="button" className="btn-secondary" onClick={handleCreateBot}>
Create bot
</button>
</div>
<table className="admin-table">
<thead>
<tr>
<th>Username</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{bots.map((b) => (
<Fragment key={b.id}>
<tr>
<td>{b.username}</td>
<td>
<span className={`status-badge ${b.is_active ? 'active' : 'inactive'}`}>
{b.is_active ? 'Active' : 'Deactivated'}
</span>
</td>
<td>{new Date(b.created_at).toLocaleDateString()}</td>
<td className="admin-actions">
<button type="button" onClick={() => toggleExpandBot(b.id)}>
{expandedBotId === b.id ? 'Hide tokens' : 'Manage tokens'}
</button>
</td>
</tr>
{expandedBotId === b.id && (
<tr>
<td colSpan={4} className="admin-bot-detail">
<div className="admin-token-list">
{(tokensByBot[b.id] ?? []).map((t) => (
<div key={t.id} className="admin-token-row">
<span className="admin-token-scopes">{t.scopes.join(', ')}</span>
<span className="admin-token-meta">
{t.last_used_at
? `last used ${new Date(t.last_used_at).toLocaleDateString()}`
: 'never used'}
</span>
<button
type="button"
className="admin-token-revoke"
disabled={busyId === t.id}
onClick={() => handleRevokeToken(b.id, t.id)}
>
Revoke
</button>
</div>
))}
{(tokensByBot[b.id] ?? []).length === 0 && (
<p className="admin-placeholder">No tokens yet.</p>
)}
</div>
<div className="admin-issue-token">
{ALL_SCOPES.map((scope) => (
<label key={scope} className="admin-scope-checkbox">
<input
type="checkbox"
checked={newTokenScopes.includes(scope)}
onChange={() => toggleScope(scope)}
/>
{scope}
</label>
))}
<button
type="button"
className="btn-secondary"
disabled={newTokenScopes.length === 0 || busyId === b.id}
onClick={() => handleIssueToken(b.id)}
>
Issue token
</button>
</div>
{justCreatedToken && (
<p className="admin-new-token">
New token (copy it now, it won't be shown again):{' '}
<code>{justCreatedToken}</code>
</p>
)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
<h2 className="admin-subheading">Registered webhooks</h2>
<table className="admin-table">
<thead>
<tr>
<th>Room</th>
<th>Type</th>
<th>Details</th>
<th>Created by</th>
</tr>
</thead>
<tbody>
{incomingWebhooks.map((w) => (
<tr key={w.id}>
<td>#{w.room_name}</td>
<td>Incoming</td>
<td>{w.description || ''}</td>
<td>{w.created_by_username}</td>
</tr>
))}
{eventSubscriptions.map((s) => (
<tr key={s.id}>
<td>{s.room_name ? `#${s.room_name}` : 'Global'}</td>
<td>Outgoing ({s.event_types.join(', ')})</td>
<td>{s.target_url}</td>
<td>{s.created_by_username}</td>
</tr>
))}
{incomingWebhooks.length === 0 && eventSubscriptions.length === 0 && (
<tr>
<td colSpan={4} className="admin-placeholder">
No webhooks registered yet.
</td>
</tr>
)}
</tbody>
</table>
</>
)}
{tab === 'audit' && (
<>
<table className="admin-table">
+70 -1
View File
@@ -58,6 +58,7 @@ export interface Message {
username: string
content: string
created_at: string
edited_at: string | null
}
export interface ChatMessageEnvelope {
@@ -68,6 +69,15 @@ export interface ChatMessageEnvelope {
username: string
content: string
created_at: string
edited_at: string | null
}
export interface ChatMessageUpdateEnvelope {
type: 'message_update'
id: string
room_id: string
content: string
edited_at: string | null
}
export interface ChatJoinedEnvelope {
@@ -80,7 +90,11 @@ export interface ChatErrorEnvelope {
detail: string
}
export type ServerEnvelope = ChatMessageEnvelope | ChatJoinedEnvelope | ChatErrorEnvelope
export type ServerEnvelope =
| ChatMessageEnvelope
| ChatMessageUpdateEnvelope
| ChatJoinedEnvelope
| ChatErrorEnvelope
export interface AdminUser {
id: string
@@ -113,3 +127,58 @@ export interface AuditLogEntry {
metadata: Record<string, unknown> | null
created_at: string
}
export type ApiScope = 'read:messages' | 'write:messages' | 'manage:rooms'
export interface Bot {
id: string
username: string
is_active: boolean
created_at: string
}
export interface ApiToken {
id: string
owner_id: string
scopes: ApiScope[]
last_used_at: string | null
created_at: string
}
export interface ApiTokenCreated extends ApiToken {
token: string
}
export interface WebhookIncoming {
id: string
room_id: string
token: string
created_by: string
description: string | null
created_at: string
}
export interface WebhookIncomingAdmin extends WebhookIncoming {
room_name: string
created_by_username: string
}
export type EventType = 'message.created' | 'message.updated'
export interface EventSubscription {
id: string
room_id: string | null
event_types: EventType[]
target_url: string
created_by: string
created_at: string
}
export interface EventSubscriptionCreated extends EventSubscription {
signing_secret: string
}
export interface EventSubscriptionAdmin extends EventSubscription {
room_name: string | null
created_by_username: string
}
+7 -1
View File
@@ -48,5 +48,11 @@ export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatS
ws.send(JSON.stringify({ type: 'message', room_id: roomId, content }))
}, [roomId])
return { connected, send }
const sendEdit = useCallback((messageId: string, content: string) => {
const ws = socketRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'edit', room_id: roomId, message_id: messageId, content }))
}, [roomId])
return { connected, send, sendEdit }
}