Phase 1: auth, room CRUD, WebSocket chat, PWA frontend

Invite-only FastAPI + SQLAlchemy(async) + Postgres backend (session-cookie
auth via CLI-provisioned accounts, open-room CRUD, single-instance /ws/chat)
and a React + Vite PWA frontend (login, room list, chat view). Backend tests
pass against a local Postgres DB. See README.md and backend/README.md for
setup, and ARCHITECTURE.md for the full phased design.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 20:01:17 -06:00
co-authored by Claude Sonnet 5
parent 8ac35062dc
commit 99aa029c0d
77 changed files with 9042 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
DATABASE_URL=postgresql+asyncpg://chatapp:chatapp@localhost:5432/chatapp
SESSION_SECRET=change-me-to-a-long-random-string
SESSION_HTTPS_ONLY=false
+107
View File
@@ -0,0 +1,107 @@
# KeepItTalking backend (Phase 1)
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. Implements auth, open-room CRUD,
and a single-instance WebSocket chat endpoint. 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.
## Local dev setup
### 1. Postgres
Any local Postgres 14+ works. The quickest option is a container:
```bash
docker run -d --name chatapp-postgres \
-e POSTGRES_USER=chatapp -e POSTGRES_PASSWORD=chatapp -e POSTGRES_DB=chatapp \
-p 5432:5432 postgres:16-alpine
```
Then create the test database (used by the test suite, kept separate from dev data):
```bash
docker exec chatapp-postgres psql -U chatapp -d chatapp -c "CREATE DATABASE chatapp_test;"
```
(Docker here is purely a local-dev convenience for standing up Postgres quickly —
the actual deployment target has no containers at all, see `ARCHITECTURE.md` §9.)
### 2. Python environment
```bash
cd backend
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
cp .env.example .env
# edit .env: set SESSION_SECRET to a long random string, e.g.
# python3 -c "import secrets; print(secrets.token_urlsafe(32))"
```
### 3. Migrations
```bash
.venv/bin/alembic upgrade head
```
### 4. Create a user
There's no public sign-up. Create accounts directly with the CLI (add
`--admin` to grant `is_site_admin`, useful ahead of the phase-6 admin portal):
```bash
.venv/bin/python -m app.cli create-user alice alice@example.com "some-password"
```
### 5. Run the dev server
```bash
.venv/bin/uvicorn app.main:app --reload
```
API docs: http://localhost:8000/docs. WebSocket chat endpoint: `ws://localhost:8000/ws/chat`.
### 6. Run tests
Tests run against a real Postgres database (`chatapp_test` by default — native
`ENUM`/`UUID` types aren't faithfully reproduced by SQLite), with each test
wrapped in a transaction that's rolled back afterward:
```bash
DATABASE_URL=postgresql+asyncpg://chatapp:chatapp@localhost:5432/chatapp_test .venv/bin/pytest
```
## Layout
```
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
security.py argon2 password hashing
cli.py `python -m app.cli create-user` (account provisioning)
models/ SQLAlchemy models (users, rooms, room_memberships, messages)
schemas/ Pydantic request/response models
routers/ auth, rooms, health
services/ business logic called by routers
ws/ WebSocket connection manager + /ws/chat handler
alembic/ migrations
tests/ pytest + httpx/TestClient tests
```
## Notes / scope decisions
- Invite-only: no `POST /api/auth/register`. Accounts are provisioned with
`python -m app.cli create-user` (see step 4 above). A more self-service
invite flow (per-user tokens, or an admin-portal "generate invite" button)
is a natural phase-2/6 follow-up, not built now.
- Sessions are signed cookies (Starlette `SessionMiddleware`), not a server-side
session table — see `ARCHITECTURE.md`'s rationale (simplest way to carry auth
through a WebSocket handshake). This means there's currently no way to force-
revoke a session server-side; that needs a real session table later.
- No CSRF token yet — `SameSite=Lax` cookies plus a same-origin frontend dev
proxy (see `../frontend/vite.config.ts`) is the accepted phase-1 mitigation.
- `rooms.is_private` exists in the schema but the API never sets it `True` yet;
private rooms/invites are phase 2 (tracked as a Gitea issue).
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql+asyncpg://chatapp:chatapp@localhost:5432/chatapp
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.
+95
View File
@@ -0,0 +1,95 @@
import asyncio
import os
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# Allow the DB URL to come from the environment (matches app/config.py),
# falling back to alembic.ini's sqlalchemy.url for local dev convenience.
db_url = os.environ.get("DATABASE_URL")
if db_url:
config.set_main_option("sqlalchemy.url", db_url)
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,85 @@
"""initial schema
Revision ID: c7981d17890c
Revises:
Create Date: 2026-08-13 19:40:11.425029
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c7981d17890c'
down_revision: Union[str, Sequence[str], None] = None
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('users',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('username', sa.String(length=50), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('is_bot', sa.Boolean(), nullable=False),
sa.Column('is_site_admin', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('rooms',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_private', sa.Boolean(), nullable=False),
sa.Column('owner_id', sa.Uuid(), nullable=False),
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_rooms_name'), 'rooms', ['name'], unique=True)
op.create_table('messages',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('room_id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('edited_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_messages_created_at'), 'messages', ['created_at'], unique=False)
op.create_index(op.f('ix_messages_room_id'), 'messages', ['room_id'], unique=False)
op.create_table('room_memberships',
sa.Column('room_id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('role', sa.Enum('owner', 'admin', 'member', name='room_role'), nullable=False),
sa.Column('joined_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('room_id', 'user_id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('room_memberships')
op.drop_index(op.f('ix_messages_room_id'), table_name='messages')
op.drop_index(op.f('ix_messages_created_at'), table_name='messages')
op.drop_table('messages')
op.drop_index(op.f('ix_rooms_name'), table_name='rooms')
op.drop_table('rooms')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###
View File
+53
View File
@@ -0,0 +1,53 @@
"""Command-line user management.
Public self-registration is disabled (invite-only site), so accounts are
created by an operator running this script directly on the app server.
"""
import argparse
import asyncio
from pydantic import ValidationError
from app.database import async_session_factory
from app.schemas.user import UserCreate
from app.services.auth_service import DuplicateUserError, register_user
async def _create_user(username: str, email: str, password: str, is_admin: bool) -> None:
try:
data = UserCreate(username=username, email=email, password=password)
except ValidationError as exc:
raise SystemExit(str(exc))
async with async_session_factory() as db:
try:
user = await register_user(db, data)
except DuplicateUserError:
raise SystemExit(f"Username or email already taken: {username} / {email}")
if is_admin:
user.is_site_admin = True
await db.commit()
print(f"Created user {username!r} (id={user.id}, admin={is_admin})")
def main() -> None:
parser = argparse.ArgumentParser(prog="python -m app.cli")
subparsers = parser.add_subparsers(dest="command", required=True)
create_user = subparsers.add_parser("create-user", help="Create a new user account")
create_user.add_argument("username")
create_user.add_argument("email")
create_user.add_argument("password")
create_user.add_argument("--admin", action="store_true", help="Grant is_site_admin")
args = parser.parse_args()
if args.command == "create-user":
asyncio.run(_create_user(args.username, args.email, args.password, args.admin))
if __name__ == "__main__":
main()
+13
View File
@@ -0,0 +1,13 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str
session_secret: str
session_https_only: bool = True
session_max_age_seconds: int = 60 * 60 * 24 * 14
settings = Settings()
+13
View File
@@ -0,0 +1,13 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
engine = create_async_engine(settings.database_url)
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session_factory() as session:
yield session
+37
View File
@@ -0,0 +1,37 @@
import uuid
from fastapi import Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import RoomMembership, User
async def get_current_user(
request: Request, db: AsyncSession = Depends(get_db)
) -> User:
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated")
user = await db.get(User, uuid.UUID(user_id))
if user is None:
request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated")
return user
async def require_room_member(
room_id: uuid.UUID, user: User, db: AsyncSession
) -> RoomMembership:
result = await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == room_id, RoomMembership.user_id == user.id
)
)
membership = result.scalar_one_or_none()
if membership is None:
raise HTTPException(status_code=403, detail="Not a member of this room")
return membership
+31
View File
@@ -0,0 +1,31 @@
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.routers import auth, health, rooms
from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager
def create_app() -> FastAPI:
app = FastAPI(title="KeepItTalking")
app.add_middleware(
SessionMiddleware,
secret_key=settings.session_secret,
same_site="lax",
https_only=settings.session_https_only,
max_age=settings.session_max_age_seconds,
)
app.state.connection_manager = ConnectionManager()
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(rooms.router)
app.include_router(ws_router)
return app
app = create_app()
+7
View File
@@ -0,0 +1,7 @@
from app.models.base import Base
from app.models.membership import RoomMembership, RoomRole
from app.models.message import Message
from app.models.room import Room
from app.models.user import User
__all__ = ["Base", "User", "Room", "RoomMembership", "RoomRole", "Message"]
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+31
View File
@@ -0,0 +1,31 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, PrimaryKeyConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class RoomRole(str, enum.Enum):
owner = "owner"
admin = "admin"
member = "member"
class RoomMembership(Base):
__tablename__ = "room_memberships"
__table_args__ = (PrimaryKeyConstraint("room_id", "user_id"),)
room_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rooms.id"))
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
role: Mapped[RoomRole] = mapped_column(
Enum(RoomRole, name="room_role"), default=RoomRole.member, nullable=False
)
joined_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
room = relationship("Room", back_populates="memberships")
user = relationship("User")
+23
View File
@@ -0,0 +1,23 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class Message(Base):
__tablename__ = "messages"
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)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
edited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
user = relationship("User")
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class Room(Base):
__tablename__ = "rooms"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
is_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
owner_id: 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
)
owner = relationship("User")
memberships = relationship(
"RoomMembership", back_populates="room", cascade="all, delete-orphan"
)
+21
View File
@@ -0,0 +1,21 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
is_bot: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_site_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
View File
+41
View File
@@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user
from app.models import User
from app.schemas.auth import LoginRequest
from app.schemas.user import UserRead
from app.services.auth_service import InvalidCredentialsError, authenticate_user
# No POST /register here: this is an invite-only site. Accounts are created
# by an operator via `python -m app.cli create-user` (see app/cli.py), not
# through a public endpoint.
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=UserRead)
async def login(
request: Request, data: LoginRequest, db: AsyncSession = Depends(get_db)
) -> User:
try:
user = await authenticate_user(
db, data.username_or_email, data.password
)
except InvalidCredentialsError:
raise HTTPException(status_code=401, detail="Invalid username/email or password")
request.session["user_id"] = str(user.id)
return user
@router.post("/logout", status_code=204)
async def logout(request: Request) -> Response:
request.session.clear()
return Response(status_code=204)
@router.get("/me", response_model=UserRead)
async def me(current_user: User = Depends(get_current_user)) -> User:
return current_user
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/api/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
+80
View File
@@ -0,0 +1,80 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user, require_room_member
from app.models import User
from app.schemas.message import MessageRead
from app.schemas.room import RoomCreate, RoomListItem, RoomRead
from app.services.message_service import list_recent_messages
from app.services.room_service import (
DuplicateRoomError,
RoomIsPrivateError,
RoomNotFoundError,
create_room,
get_room,
join_room,
list_open_rooms,
)
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
@router.post("", response_model=RoomRead, status_code=201)
async def create_room_endpoint(
data: RoomCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
return await create_room(db, current_user.id, data)
except DuplicateRoomError:
raise HTTPException(status_code=409, detail="A room with this name already exists")
@router.get("", response_model=list[RoomListItem])
async def list_rooms_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
rooms = await list_open_rooms(db, current_user.id)
return [
RoomListItem(
id=room.id,
name=room.name,
description=room.description,
is_private=room.is_private,
owner_id=room.owner_id,
created_at=room.created_at,
is_member=is_member,
)
for room, is_member in rooms
]
@router.post("/{room_id}/join", response_model=RoomRead)
async def join_room_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
await join_room(db, room_id, current_user.id)
return await get_room(db, room_id)
except RoomNotFoundError:
raise HTTPException(status_code=404, detail="Room not found")
except RoomIsPrivateError:
raise HTTPException(status_code=400, detail="Cannot join a private room directly")
@router.get("/{room_id}/messages", response_model=list[MessageRead])
async def get_room_messages_endpoint(
room_id: uuid.UUID,
limit: int = Query(default=50, ge=1, le=200),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_member(room_id, current_user, db)
return await list_recent_messages(db, room_id, limit)
View File
+6
View File
@@ -0,0 +1,6 @@
from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
username_or_email: str = Field(min_length=1)
password: str = Field(min_length=1)
+14
View File
@@ -0,0 +1,14 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class MessageRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
room_id: uuid.UUID
user_id: uuid.UUID
content: str
created_at: datetime
+24
View File
@@ -0,0 +1,24 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class RoomCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=2000)
class RoomRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
is_private: bool
owner_id: uuid.UUID
created_at: datetime
class RoomListItem(RoomRead):
is_member: bool
+21
View File
@@ -0,0 +1,21 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=50)
email: EmailStr
password: str = Field(min_length=8, max_length=200)
class UserRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
username: str
email: EmailStr
is_bot: bool
is_site_admin: bool
created_at: datetime
+15
View File
@@ -0,0 +1,15 @@
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
_hasher = PasswordHasher()
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except VerifyMismatchError:
return False
View File
+46
View File
@@ -0,0 +1,46 @@
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import User
from app.schemas.user import UserCreate
from app.security import hash_password, verify_password
class DuplicateUserError(Exception):
pass
class InvalidCredentialsError(Exception):
pass
async def register_user(db: AsyncSession, data: UserCreate) -> User:
user = User(
username=data.username,
email=data.email,
password_hash=hash_password(data.password),
)
db.add(user)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise DuplicateUserError() from exc
await db.refresh(user)
return user
async def authenticate_user(
db: AsyncSession, username_or_email: str, password: str
) -> User:
result = await db.execute(
select(User).where(
or_(User.username == username_or_email, User.email == username_or_email)
)
)
user = result.scalar_one_or_none()
if user is None or not verify_password(password, user.password_hash):
raise InvalidCredentialsError()
return user
+30
View File
@@ -0,0 +1,30 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Message
async def create_message(
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID, content: str
) -> Message:
message = Message(room_id=room_id, user_id=user_id, content=content)
db.add(message)
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]:
result = await db.execute(
select(Message)
.where(Message.room_id == room_id)
.order_by(Message.created_at.desc())
.limit(limit)
)
messages = list(result.scalars().all())
messages.reverse()
return messages
+77
View File
@@ -0,0 +1,77 @@
import uuid
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Room, RoomMembership, RoomRole
from app.schemas.room import RoomCreate
class DuplicateRoomError(Exception):
pass
class RoomNotFoundError(Exception):
pass
class RoomIsPrivateError(Exception):
pass
async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room:
room = Room(name=data.name, description=data.description, owner_id=owner_id)
db.add(room)
try:
await db.flush()
except IntegrityError as exc:
await db.rollback()
raise DuplicateRoomError() from exc
db.add(RoomMembership(room_id=room.id, user_id=owner_id, role=RoomRole.owner))
await db.commit()
await db.refresh(room)
return room
async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Room, bool]]:
result = await db.execute(
select(Room)
.where(Room.is_private.is_(False))
.options(selectinload(Room.memberships))
.order_by(Room.created_at)
)
rooms = result.scalars().all()
return [
(room, any(m.user_id == user_id for m in room.memberships)) for room in rooms
]
async def get_room(db: AsyncSession, room_id: uuid.UUID) -> Room:
room = await db.get(Room, room_id)
if room is None:
raise RoomNotFoundError()
return room
async def join_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> RoomMembership:
room = await get_room(db, room_id)
if room.is_private:
raise RoomIsPrivateError()
result = await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
)
)
membership = result.scalar_one_or_none()
if membership is not None:
return membership
membership = RoomMembership(room_id=room_id, user_id=user_id, role=RoomRole.member)
db.add(membership)
await db.commit()
await db.refresh(membership)
return membership
View File
+112
View File
@@ -0,0 +1,112 @@
import uuid
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import RoomMembership, User
from app.services.message_service import create_message
router = APIRouter(tags=["ws"])
WS_UNAUTHENTICATED = 4401
class ClientEnvelope(BaseModel):
type: str
room_id: uuid.UUID | None = None
content: str | None = None
async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> bool:
result = await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
)
)
return result.scalar_one_or_none() is not None
@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
user = await db.get(User, uuid.UUID(user_id_raw))
if user is None:
await websocket.close(code=WS_UNAUTHENTICATED)
return
await websocket.accept()
manager = websocket.app.state.connection_manager
joined_rooms: set[uuid.UUID] = set()
try:
while True:
raw = await websocket.receive_json()
try:
envelope = ClientEnvelope.model_validate(raw)
except ValidationError:
await websocket.send_json({"type": "error", "detail": "Malformed message"})
continue
if envelope.type == "join":
if envelope.room_id is None:
await websocket.send_json({"type": "error", "detail": "room_id required"})
continue
if 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
manager.join(envelope.room_id, websocket)
joined_rooms.add(envelope.room_id)
await websocket.send_json({"type": "joined", "room_id": str(envelope.room_id)})
elif envelope.type == "leave":
if envelope.room_id is None:
await websocket.send_json({"type": "error", "detail": "room_id required"})
continue
manager.leave(envelope.room_id, websocket)
joined_rooms.discard(envelope.room_id)
elif envelope.type == "message":
if envelope.room_id is None or not envelope.content:
await websocket.send_json(
{"type": "error", "detail": "room_id and content required"}
)
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
message = await create_message(db, envelope.room_id, user.id, envelope.content)
await manager.broadcast(
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(),
},
)
else:
await websocket.send_json(
{"type": "error", "detail": f"Unknown message type: {envelope.type}"}
)
except WebSocketDisconnect:
pass
finally:
manager.leave_all(websocket)
+31
View File
@@ -0,0 +1,31 @@
import uuid
from collections import defaultdict
from fastapi import WebSocket
class ConnectionManager:
"""In-memory, single-process WebSocket registry.
Correct for a single app-server instance only; cross-instance fan-out via
Redis pub/sub is a later phase (ARCHITECTURE.md phase 5).
"""
def __init__(self) -> None:
self._rooms: dict[uuid.UUID, set[WebSocket]] = defaultdict(set)
def join(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
self._rooms[room_id].add(websocket)
def leave(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
self._rooms[room_id].discard(websocket)
if not self._rooms[room_id]:
del self._rooms[room_id]
def leave_all(self, websocket: WebSocket) -> None:
for room_id in list(self._rooms.keys()):
self.leave(room_id, websocket)
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
for websocket in list(self._rooms.get(room_id, ())):
await websocket.send_json(payload)
+37
View File
@@ -0,0 +1,37 @@
[project]
name = "chatapp"
version = "0.1.0"
description = "KeepItTalking chat service backend"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"sqlalchemy>=2.0.36",
"asyncpg>=0.30",
"alembic>=1.14",
"pydantic>=2.9",
"pydantic[email]>=2.9",
"pydantic-settings>=2.6",
"argon2-cffi>=23.1",
"itsdangerous>=2.2",
]
[project.scripts]
chatapp-create-user = "app.cli:main"
[project.optional-dependencies]
dev = [
"pytest>=8",
"pytest-asyncio>=0.24",
"httpx>=0.27",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
View File
+114
View File
@@ -0,0 +1,114 @@
import os
from pathlib import Path
os.environ.setdefault(
"DATABASE_URL", "postgresql+asyncpg://chatapp:chatapp@localhost:5432/chatapp_test"
)
os.environ.setdefault("SESSION_SECRET", "test-secret")
os.environ.setdefault("SESSION_HTTPS_ONLY", "false")
import pytest
import pytest_asyncio
from alembic import command
from alembic.config import Config
from fastapi.testclient import TestClient
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import get_db
from app.main import create_app
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
BACKEND_DIR = Path(__file__).resolve().parent.parent
TEST_DATABASE_URL = os.environ["DATABASE_URL"]
@pytest.fixture(scope="session", autouse=True)
def apply_migrations():
config = Config(str(BACKEND_DIR / "alembic.ini"))
config.set_main_option("script_location", str(BACKEND_DIR / "alembic"))
command.upgrade(config, "head")
yield
@pytest_asyncio.fixture
async def db_session():
# Function-scoped (not session-scoped): asyncpg connections are bound to
# the event loop they were created on, and pytest-asyncio gives each test
# function its own loop by default. A session-scoped engine here would be
# reused across loops and fail with asyncpg "another operation is in
# progress" errors.
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.connect() as conn:
await conn.begin()
session = AsyncSession(bind=conn, join_transaction_mode="create_savepoint")
yield session
await session.close()
await conn.rollback()
await engine.dispose()
@pytest.fixture
def app(db_session):
application = create_app()
async def _get_db():
yield db_session
application.dependency_overrides[get_db] = _get_db
yield application
application.dependency_overrides.clear()
@pytest_asyncio.fixture
async def client(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
def ws_client():
# Starlette's TestClient (needed for websocket_connect, which httpx's
# async client doesn't support) runs the ASGI app on a background thread
# with its own event loop via anyio's BlockingPortal. asyncpg connections
# are bound to the loop they're opened on, so this app gets its own
# engine created here (no connections opened yet) rather than reusing
# the `db_session`/`app` fixtures' engine, which belongs to pytest's
# loop. No per-test rollback here (see test_ws_chat.py for the
# unique-name convention that keeps tests independent without it).
application = create_app()
test_engine = create_async_engine(TEST_DATABASE_URL)
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
async def _get_db():
async with test_session_factory() as session:
yield session
application.dependency_overrides[get_db] = _get_db
with TestClient(application) as tc:
tc.session_factory = test_session_factory # type: ignore[attr-defined]
yield tc
async def register_and_login(
client: AsyncClient,
db_session: AsyncSession,
username: str = "alice",
password: str = "password123",
):
# No public register endpoint (invite-only site) -- tests seed the
# account the same way an operator would via `python -m app.cli
# create-user`, by calling the service function directly, then log in
# through the real endpoint to get a session cookie on `client`.
data = UserCreate(username=username, email=f"{username}@example.com", password=password)
await register_user(db_session, data)
resp = await client.post(
"/api/auth/login",
json={"username_or_email": username, "password": password},
)
assert resp.status_code == 200, resp.text
return resp.json()
+70
View File
@@ -0,0 +1,70 @@
import pytest
from app.schemas.user import UserCreate
from app.services.auth_service import DuplicateUserError, register_user
from tests.conftest import register_and_login
async def test_login_sets_session_and_me_returns_user(client, db_session):
user = await register_and_login(client, db_session, username="alice")
assert user["username"] == "alice"
assert user["email"] == "alice@example.com"
resp = await client.get("/api/auth/me")
assert resp.status_code == 200
assert resp.json()["id"] == user["id"]
async def test_login_wrong_password(client, db_session):
data = UserCreate(username="erin", email="erin@example.com", password="password123")
await register_user(db_session, data)
resp = await client.post(
"/api/auth/login",
json={"username_or_email": "erin", "password": "wrong-password"},
)
assert resp.status_code == 401
async def test_login_unknown_user(client):
resp = await client.post(
"/api/auth/login",
json={"username_or_email": "nobody", "password": "password123"},
)
assert resp.status_code == 401
async def test_me_requires_auth(client):
resp = await client.get("/api/auth/me")
assert resp.status_code == 401
async def test_logout_clears_session(client, db_session):
await register_and_login(client, db_session, username="frank")
resp = await client.post("/api/auth/logout")
assert resp.status_code == 204
resp = await client.get("/api/auth/me")
assert resp.status_code == 401
async def test_register_user_duplicate_username_conflicts(db_session):
await register_user(
db_session, UserCreate(username="bob", email="bob@example.com", password="password123")
)
with pytest.raises(DuplicateUserError):
await register_user(
db_session,
UserCreate(username="bob", email="different@example.com", password="password123"),
)
async def test_register_user_duplicate_email_conflicts(db_session):
await register_user(
db_session, UserCreate(username="carol", email="carol@example.com", password="password123")
)
with pytest.raises(DuplicateUserError):
await register_user(
db_session,
UserCreate(username="different", email="carol@example.com", password="password123"),
)
+77
View File
@@ -0,0 +1,77 @@
import uuid
from sqlalchemy import select
from app.models import Room, RoomMembership, RoomRole
from tests.conftest import register_and_login
async def test_create_room_requires_auth(client):
resp = await client.post("/api/rooms", json={"name": "general"})
assert resp.status_code == 401
async def test_create_room_creates_owner_membership(client, db_session):
user = await register_and_login(client, db_session, username="alice")
resp = await client.post("/api/rooms", json={"name": "general", "description": "chat"})
assert resp.status_code == 201
room = resp.json()
assert room["name"] == "general"
assert room["owner_id"] == user["id"]
result = await db_session.execute(
select(RoomMembership).where(RoomMembership.room_id == uuid.UUID(room["id"]))
)
membership = result.scalar_one()
assert membership.user_id == uuid.UUID(user["id"])
assert membership.role == RoomRole.owner
async def test_list_rooms_excludes_private(client, db_session):
user = await register_and_login(client, db_session, username="alice")
await client.post("/api/rooms", json={"name": "open-room"})
private_room = Room(
name="secret-room", is_private=True, owner_id=uuid.UUID(user["id"])
)
db_session.add(private_room)
await db_session.commit()
resp = await client.get("/api/rooms")
assert resp.status_code == 200
names = {r["name"] for r in resp.json()}
assert "open-room" in names
assert "secret-room" not in names
async def test_join_room_idempotent(client, db_session):
await register_and_login(client, db_session, username="alice")
create_resp = await client.post("/api/rooms", json={"name": "general"})
room_id = create_resp.json()["id"]
await client.post("/api/auth/logout")
await register_and_login(client, db_session, username="bob")
resp1 = await client.post(f"/api/rooms/{room_id}/join")
assert resp1.status_code == 200
resp2 = await client.post(f"/api/rooms/{room_id}/join")
assert resp2.status_code == 200
async def test_join_nonexistent_room_404(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.post(f"/api/rooms/{uuid.uuid4()}/join")
assert resp.status_code == 404
async def test_join_private_room_400(client, db_session):
user = await register_and_login(client, db_session, username="alice")
private_room = Room(
name="secret-room", is_private=True, owner_id=uuid.UUID(user["id"])
)
db_session.add(private_room)
await db_session.commit()
await db_session.refresh(private_room)
resp = await client.post(f"/api/rooms/{private_room.id}/join")
assert resp.status_code == 400
+74
View File
@@ -0,0 +1,74 @@
import uuid
from starlette.websockets import WebSocketDisconnect
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
from app.ws.chat import WS_UNAUTHENTICATED
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _register(ws_client, username):
# No public register endpoint (invite-only site): seed the user directly
# via the ws_client's own session factory (see conftest.ws_client), then
# log in through the real endpoint to get a session cookie.
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_requires_auth(ws_client):
try:
with ws_client.websocket_connect("/ws/chat"):
pass
assert False, "expected the connection to be rejected"
except WebSocketDisconnect as exc:
assert exc.code == WS_UNAUTHENTICATED
def test_ws_join_and_message_roundtrip(ws_client):
username = _unique("alice")
_register(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"]})
joined = ws.receive_json()
assert joined == {"type": "joined", "room_id": room["id"]}
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
message = ws.receive_json()
assert message["type"] == "message"
assert message["content"] == "hello"
assert message["room_id"] == room["id"]
assert message["username"] == username
resp = ws_client.get(f"/api/rooms/{room['id']}/messages")
assert resp.status_code == 200
contents = [m["content"] for m in resp.json()]
assert "hello" in contents
def test_ws_message_without_join_errors(ws_client):
_register(ws_client, username=_unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
resp = ws.receive_json()
assert resp["type"] == "error"