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
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "frontend", "run", "dev"],
"port": 5173
}
]
}
+24
View File
@@ -0,0 +1,24 @@
## Python
__pycache__/
*.pyc
.venv/
*.egg-info/
## Env / secrets
.env
!.env.example
## Node / frontend
node_modules/
dist/
dist-ssr/
*.local
## Editors / OS
.vscode/
.idea/
.DS_Store
## Test / coverage
.pytest_cache/
.coverage
+282
View File
@@ -0,0 +1,282 @@
# Chat service — architecture document
## 1. Overview
A web-based team chat service, similar in spirit to Mattermost, with no threaded
conversations. Core features:
- Chat rooms that are either **open** (anyone can join) or **invite-only** (private)
- A **PWA** client — single codebase serves desktop, mobile web, and an installable
app experience
- **Push notifications** for offline/backgrounded users
- A **full admin portal** for site administration
- An **extension system** for bots and AI agents (webhooks, scoped API tokens,
live WebSocket access)
Deployment target: two plain Linux servers, no containers. One server runs the
database and Redis; the other runs the application and serves the frontend.
## 2. Tech stack
| Layer | Choice | Why |
|---|---|---|
| Backend | Python, FastAPI (async) | Async-native, fits many concurrent WebSocket connections without extra layers |
| ORM / migrations | SQLAlchemy 2.0 (async) + Alembic | Mature async ORM, explicit schema migrations |
| Database | PostgreSQL | Relational structure fits users/rooms/memberships/messages well |
| Cross-instance broadcast | Redis (pub/sub) | Lets multiple app server processes fan out messages to all connected clients |
| Push notifications | pywebpush + VAPID | Standard Web Push, works on Android and iOS 16.4+ (PWA must be installed to home screen on iOS) |
| Frontend | React + Vite, vite-plugin-pwa | Generates the manifest and service worker for install + push |
| Reverse proxy / TLS | Nginx + Let's Encrypt (certbot) | Terminates TLS, serves static assets, proxies REST + WebSocket traffic |
| Process management | systemd | No Docker — native to the target Linux distro, no extra install |
| Auth | Session cookies (httpOnly, secure) | Simplest to carry through a WebSocket handshake automatically |
## 3. System architecture
### 3.1 Core message flow
```
PWA client (browser + service worker)
| REST + WebSocket
v
FastAPI app server (N instances behind Nginx)
| | |
v v v
PostgreSQL Redis pub/sub Push service (pywebpush)
(persistence) (fan-out across (delivers to offline
app instances) clients via Web Push)
```
A message is persisted to Postgres, published to a Redis channel scoped to its
room, and every app server instance subscribed to that channel forwards it over
WebSocket to its own connected clients who are members. Members who are not
currently connected get a Web Push notification instead, looked up from their
stored push subscription.
Redis only matters once more than one app server process is running. A single
instance can skip it entirely and add it later without changing anything else.
### 3.2 Admin and extension layer
```
Admin portal Bots / AI agents
| |
v v
API gateway
(scoped tokens + admin role checks)
|
v
Core chat server
(rooms, messages, permissions)
|
v (WebSocket events, dashed = async)
back out to bots/agents
```
Admin portal and bots/agents are both just API consumers, differentiated by the
credentials they carry: session + `is_site_admin` flag for the portal, scoped API
tokens for bots. Bots can also hold a live WebSocket connection to receive room
events in real time and post message updates — the same mechanism a human client
uses.
## 4. Data model
```
users
id, username, email, password_hash, is_bot, is_site_admin, created_at
rooms
id, name, description, is_private, owner_id, created_at
room_memberships
room_id, user_id, role (owner | admin | member), joined_at
room_invites
id, room_id, invited_by, token, target_user_id or target_email,
expires_at, status (pending | accepted | revoked)
messages
id, room_id, user_id, content, created_at, edited_at, deleted_at
push_subscriptions
id, user_id, endpoint, p256dh_key, auth_key, created_at
api_tokens
id, owner_id (user or bot), token_hash, scopes[], last_used_at, created_at
webhooks_incoming
id, room_id, token, created_by, description
event_subscriptions
id, room_id (nullable = global), event_types[], target_url,
signing_secret, created_by
admin_audit_log
id, actor_id, action, target_type, target_id, metadata, created_at
```
## 5. Permission model
- **Room visibility**: `open` (any authenticated user can find and join) or
`private` (visible only to members, joinable only via invite).
- **Room roles**: `owner` (delete room, transfer ownership), `admin` (invite/remove
members, edit settings), `member` (post, leave).
- **Site-level**: `is_site_admin` on the user record, checked for every admin
portal route.
- Every room action is authorized server-side against `room_memberships` — never
trust a client's claim about its own role or membership.
## 6. Real-time and push notification flow
1. Client sends a message over its open WebSocket.
2. Server checks the sender is a member of the room, persists the message.
3. Server publishes the message to the room's Redis pub/sub channel.
4. Every app instance subscribed to that channel forwards it to its own connected
members over WebSocket.
5. For members with no active connection, the server looks up
`push_subscriptions` and sends a Web Push notification via `pywebpush`.
## 7. Extension system: bots and AI agents
Extensions run **outside** the server process and talk to it over the network —
no in-process plugin runtime, no sandboxing to build. This is deliberately the
lighter-weight option, and it matches how AI agents naturally integrate: as an
HTTP/WebSocket client.
- **Bot accounts**: a row in `users` with `is_bot = true`. Can be added to rooms
and post messages exactly like a human account.
- **Scoped API tokens**: e.g. `read:messages`, `write:messages`, `manage:rooms`,
issued per bot from the admin portal, hashed at rest, shown once at creation.
- **Incoming webhooks**: a room-scoped URL an external service can POST a message
to. No auth flow beyond the URL being a secret.
- **Outgoing webhooks / event subscriptions**: the server POSTs to a registered
URL when matching events happen, signed with `signing_secret` so the receiver
can verify authenticity.
- **Live WebSocket access for bots**: same connection type the PWA client uses,
authenticated with a bot token. Lets a bot or AI agent see messages as they
arrive and reply without polling.
- **Message update events**: beyond create/edit/delete, support patching an
existing message's content. This lets an AI agent post a placeholder and stream
tokens into it live, the same pattern Slack/Discord bots use.
Security notes: validate outgoing webhook target URLs to block requests into
internal network ranges (SSRF), rate-limit bot API calls the same as human ones,
and log bot actions to `admin_audit_log`.
## 8. Admin portal
Built as protected routes inside the same React PWA (`/admin/*`), gated by
`is_site_admin` on the session — no separate app or deployment to maintain.
Features:
- User management: list, deactivate, reset password, promote to site admin
- Room management: view all rooms (including private), transfer ownership,
force-archive
- Bot/integration management: create bots, generate/revoke tokens, set scopes,
view registered webhooks
- System settings: open vs invite-only registration, file size limits, branding
- Audit log viewer
For fast internal CRUD scaffolding on top of the SQLAlchemy models, consider
[SQLAdmin](https://aminalaee.dev/sqladmin/) mounted on an internal-only path —
useful for raw table management while custom logic (moderation, bot tokens,
audit views) gets built separately.
## 9. Deployment architecture — two Linux servers, no Docker
### 9.1 Data server
Runs PostgreSQL and Redis.
- Bind Postgres and Redis to the private network interface only, never `0.0.0.0`
on a public interface.
- Firewall (`ufw` or `iptables`): allow port 5432 (Postgres) and 6379 (Redis)
only from the app server's IP address.
- If the hosting provider doesn't offer a private network between the two
servers, put a WireGuard tunnel between them and bind services to the tunnel
interface instead of trusting a firewall rule alone over the public internet.
- Backups: nightly `pg_dump` via a cron job, rotated and shipped off-box.
### 9.2 App server
Runs the FastAPI app and Nginx; serves the built PWA static files.
- Python virtualenv, application installed via `pip install -e .` or similar.
- App run via Gunicorn with Uvicorn workers, one process per CPU core as a
starting point, managed by a systemd unit:
```ini
# /etc/systemd/system/chatapp.service
[Unit]
Description=Chat service app server
After=network.target
[Service]
User=chatapp
WorkingDirectory=/srv/chatapp
EnvironmentFile=/etc/chatapp/env
ExecStart=/srv/chatapp/venv/bin/gunicorn app.main:app \
-k uvicorn.workers.UvicornWorker \
--workers 4 \
--bind unix:/run/chatapp/chatapp.sock
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
- Nginx terminates TLS (certbot-managed certificate), serves the built frontend
assets directly, and reverse-proxies API and WebSocket traffic to the Unix
socket:
```nginx
server {
listen 443 ssl;
server_name chat.example.com;
root /srv/chatapp/frontend/dist;
try_files $uri /index.html;
location /api/ {
proxy_pass http://unix:/run/chatapp/chatapp.sock;
proxy_set_header Host $host;
}
location /ws/ {
proxy_pass http://unix:/run/chatapp/chatapp.sock;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
```
- Secrets (database URL pointing at the data server's private IP, Redis URL,
VAPID keys, session secret) live in `/etc/chatapp/env`, loaded via
`EnvironmentFile=`, never committed to the repository.
- Deploy process: `git pull`, install/update dependencies, `alembic upgrade
head`, build the frontend, `systemctl restart chatapp`, `nginx -s reload` if
the Nginx config changed.
- Logs: `journalctl -u chatapp`, rotated by systemd/journald defaults; add
`logrotate` if the app also writes its own log files.
## 10. Security considerations
- Server-side authorization on every room and message action — never trust
client-supplied role/membership claims.
- Database bound to the private network only, firewalled to the app server's IP.
- API tokens and webhook secrets hashed/stored securely, shown once at creation.
- Outgoing webhook URLs validated against internal IP ranges to prevent SSRF.
- Rate limiting on both human and bot API traffic.
- TLS everywhere in transit (Nginx-terminated for clients; a WireGuard tunnel or
equivalent for cross-server DB traffic if not on a trusted private network).
## 11. Phased build plan
1. Auth, room CRUD, open rooms, single-instance WebSocket messaging
2. Private rooms, invites, roles
3. PWA shell — manifest, service worker, offline caching
4. Push notification subscription + delivery
5. Redis pub/sub for horizontal scaling across app server instances
6. Admin portal
7. Bot/extension system: tokens, webhooks, bot WebSocket access, message updates
+52
View File
@@ -0,0 +1,52 @@
# KeepItTalking
A web-based team chat service (Mattermost-style, no threaded conversations),
invite-only. See [ARCHITECTURE.md](ARCHITECTURE.md) for the full system design
and phased build plan.
**Phase 1** (this state of the repo): auth, open-room CRUD, and single-instance
WebSocket chat, backend + a minimal frontend. Later phases (private rooms,
push notifications, Redis fan-out, the admin portal, the bot/extension
system, and production deployment) are tracked as issues in the repo's issue
tracker, prioritized.
## Structure
- [`backend/`](backend/) — FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL. See
[`backend/README.md`](backend/README.md) for local setup, migrations, and
how to create a user (registration is invite-only — there's no public
sign-up endpoint).
- [`frontend/`](frontend/) — React + Vite PWA (login, room list, chat view).
## Quickstart
```bash
# 1. Postgres (see backend/README.md for details)
docker run -d --name chatapp-postgres \
-e POSTGRES_USER=chatapp -e POSTGRES_PASSWORD=chatapp -e POSTGRES_DB=chatapp \
-p 5432:5432 postgres:16-alpine
# 2. Backend
cd backend
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
cp .env.example .env # then set SESSION_SECRET
.venv/bin/alembic upgrade head
.venv/bin/python -m app.cli create-user alice alice@example.com "some-password"
.venv/bin/uvicorn app.main:app --reload &
# 3. Frontend (in another shell)
cd frontend
npm install
npm run dev
```
Then open http://localhost:5173 and log in with the account created above.
The Vite dev server proxies `/api` and `/ws` to the backend on `:8000`, so no
CORS configuration is needed in development.
## Deployment
Not part of Phase 1. The target is two plain Linux servers with no
containers — see [ARCHITECTURE.md §9](ARCHITECTURE.md#9-deployment-architecture--two-linux-servers-no-docker)
and the corresponding "Production deployment" issue in the tracker.
+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"
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+36
View File
@@ -0,0 +1,36 @@
# KeepItTalking frontend (Phase 1)
React + Vite PWA. Login, room list, and chat views wired to the backend's
REST API and `/ws/chat` WebSocket endpoint. See [`../README.md`](../README.md)
and [`../backend/README.md`](../backend/README.md) for full local setup.
## Dev
```bash
npm install
npm run dev
```
The dev server proxies `/api` and `/ws` to `http://localhost:8000` (see
`vite.config.ts`), so the backend must be running for anything beyond the
login page to work.
## Build
```bash
npm run build
```
Generates the PWA manifest and service worker via `vite-plugin-pwa` into `dist/`.
## Layout
```
src/
main.tsx, App.tsx routes: /login, /rooms, /rooms/:roomId
api/ fetch wrappers (client, auth, rooms)
ws/useChatSocket.ts WebSocket hook (join/send/receive)
context/AuthContext.tsx current-user state, hydrated via GET /api/auth/me
components/ ProtectedRoute, RoomListItem, MessageList, MessageInput
pages/ LoginPage, RoomListPage, ChatRoomPage
```
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KeepItTalking</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6218
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"vite-plugin-pwa": "^1.3.0"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"oxlint": "^1.75.0",
"typescript": "~6.0.2",
"vite": "^8.2.0"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

+35
View File
@@ -0,0 +1,35 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext'
import { ProtectedRoute } from './components/ProtectedRoute'
import { LoginPage } from './pages/LoginPage'
import { RoomListPage } from './pages/RoomListPage'
import { ChatRoomPage } from './pages/ChatRoomPage'
function App() {
return (
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/rooms"
element={
<ProtectedRoute>
<RoomListPage />
</ProtectedRoute>
}
/>
<Route
path="/rooms/:roomId"
element={
<ProtectedRoute>
<ChatRoomPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/rooms" replace />} />
</Routes>
</AuthProvider>
)
}
export default App
+21
View File
@@ -0,0 +1,21 @@
import { apiFetch } from './client'
import type { User } from '../types'
// No register() here: this is an invite-only site. Accounts are created by
// an operator via the backend CLI (`python -m app.cli create-user`), not
// through a public endpoint.
export function login(usernameOrEmail: string, password: string): Promise<User> {
return apiFetch<User>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username_or_email: usernameOrEmail, password }),
})
}
export function logout(): Promise<void> {
return apiFetch<void>('/api/auth/logout', { method: 'POST' })
}
export function me(): Promise<User> {
return apiFetch<User>('/api/auth/me')
}
+33
View File
@@ -0,0 +1,33 @@
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
...init,
})
if (!response.ok) {
let detail = response.statusText
try {
const body = await response.json()
detail = body.detail ?? detail
} catch {
// response had no JSON body
}
throw new ApiError(response.status, detail)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
+21
View File
@@ -0,0 +1,21 @@
import { apiFetch } from './client'
import type { Message, Room, RoomListItem } from '../types'
export function listRooms(): Promise<RoomListItem[]> {
return apiFetch<RoomListItem[]>('/api/rooms')
}
export function createRoom(name: string, description?: string): Promise<Room> {
return apiFetch<Room>('/api/rooms', {
method: 'POST',
body: JSON.stringify({ name, description: description || null }),
})
}
export function joinRoom(roomId: string): Promise<Room> {
return apiFetch<Room>(`/api/rooms/${roomId}/join`, { method: 'POST' })
}
export function getRoomMessages(roomId: string): Promise<Message[]> {
return apiFetch<Message[]>(`/api/rooms/${roomId}/messages`)
}
+33
View File
@@ -0,0 +1,33 @@
import { useState, type FormEvent } from 'react'
interface MessageInputProps {
disabled?: boolean
onSend: (content: string) => void
}
export function MessageInput({ disabled, onSend }: MessageInputProps) {
const [value, setValue] = useState('')
function handleSubmit(e: FormEvent) {
e.preventDefault()
const trimmed = value.trim()
if (!trimmed) return
onSend(trimmed)
setValue('')
}
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '0.5rem', padding: '0.5rem' }}>
<input
style={{ flex: 1 }}
value={value}
disabled={disabled}
onChange={(e) => setValue(e.target.value)}
placeholder="Message..."
/>
<button type="submit" disabled={disabled || !value.trim()}>
Send
</button>
</form>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useRef } from 'react'
import type { ChatMessageEnvelope, Message } from '../types'
interface DisplayMessage {
id: string
username: string
content: string
created_at: string
}
interface MessageListProps {
messages: (Message | ChatMessageEnvelope)[]
usernames: Record<string, string>
}
export function MessageList({ messages, usernames }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: 'end' })
}, [messages.length])
const display: DisplayMessage[] = messages.map((m) => ({
id: m.id,
content: m.content,
created_at: m.created_at,
username: 'username' in m ? m.username : usernames[m.user_id] ?? m.user_id,
}))
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '0.5rem' }}>
{display.map((m) => (
<div key={m.id} style={{ marginBottom: '0.5rem' }}>
<strong>{m.username}</strong>{' '}
<span style={{ color: '#888', fontSize: '0.8em' }}>
{new Date(m.created_at).toLocaleTimeString()}
</span>
<div>{m.content}</div>
</div>
))}
<div ref={bottomRef} />
</div>
)
}
@@ -0,0 +1,12 @@
import type { ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export function ProtectedRoute({ children }: { children: ReactNode }) {
const { user, loading } = useAuth()
if (loading) return <p>Loading...</p>
if (!user) return <Navigate to="/login" replace />
return <>{children}</>
}
+26
View File
@@ -0,0 +1,26 @@
import { Link } from 'react-router-dom'
import type { RoomListItem as RoomListItemType } from '../types'
interface RoomListItemProps {
room: RoomListItemType
onJoin: (roomId: string) => void
joining: boolean
}
export function RoomListItem({ room, onJoin, joining }: RoomListItemProps) {
return (
<li style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.5rem 0' }}>
<div style={{ flex: 1 }}>
<strong>{room.name}</strong>
{room.description && <div style={{ color: '#666' }}>{room.description}</div>}
</div>
{room.is_member ? (
<Link to={`/rooms/${room.id}`}>Open</Link>
) : (
<button disabled={joining} onClick={() => onJoin(room.id)}>
Join
</button>
)}
</li>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import * as authApi from '../api/auth'
import { ApiError } from '../api/client'
import type { User } from '../types'
interface AuthContextValue {
user: User | null
loading: boolean
login: (usernameOrEmail: string, password: string) => Promise<void>
logout: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
authApi
.me()
.then(setUser)
.catch((err) => {
if (!(err instanceof ApiError && err.status === 401)) {
console.error('Failed to load current user', err)
}
})
.finally(() => setLoading(false))
}, [])
async function login(usernameOrEmail: string, password: string) {
setUser(await authApi.login(usernameOrEmail, password))
}
async function logout() {
await authApi.logout()
setUser(null)
}
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within an AuthProvider')
return ctx
}
+20
View File
@@ -0,0 +1,20 @@
:root {
font-family: system-ui, sans-serif;
color-scheme: light dark;
}
body {
margin: 0;
}
input,
button {
font: inherit;
padding: 0.4rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
+58
View File
@@ -0,0 +1,58 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { getRoomMessages } from '../api/rooms'
import { MessageInput } from '../components/MessageInput'
import { MessageList } from '../components/MessageList'
import { useAuth } from '../context/AuthContext'
import { useChatSocket } from '../ws/useChatSocket'
import type { ChatMessageEnvelope, Message, ServerEnvelope } from '../types'
export function ChatRoomPage() {
const { roomId } = useParams<{ roomId: string }>()
const { user } = useAuth()
const navigate = useNavigate()
const [history, setHistory] = useState<Message[]>([])
const [live, setLive] = useState<ChatMessageEnvelope[]>([])
const [wsError, setWsError] = useState<string | null>(null)
useEffect(() => {
if (!roomId) return
setHistory([])
setLive([])
getRoomMessages(roomId).then(setHistory).catch((err) => setWsError(String(err)))
}, [roomId])
const onMessage = useCallback((envelope: ServerEnvelope) => {
if (envelope.type === 'message') {
setLive((prev) => [...prev, envelope])
} else if (envelope.type === 'error') {
setWsError(envelope.detail)
}
}, [])
const onUnauthenticated = useCallback(() => navigate('/login'), [navigate])
const { connected, send } = useChatSocket({
roomId: roomId ?? '',
onMessage,
onUnauthenticated,
})
if (!roomId) return null
const usernames = user ? { [user.id]: user.username } : {}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', maxWidth: 640, margin: '0 auto' }}>
<header style={{ padding: '0.5rem', borderBottom: '1px solid #ddd' }}>
<button onClick={() => navigate('/rooms')}>&larr; Rooms</button>
{!connected && <span style={{ marginLeft: '1rem', color: '#888' }}>Connecting...</span>}
</header>
{wsError && <p style={{ color: 'red', padding: '0 0.5rem' }}>{wsError}</p>}
<MessageList messages={[...history, ...live]} usernames={usernames} />
<MessageInput disabled={!connected} onSend={send} />
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { useState, type FormEvent } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { ApiError } from '../api/client'
export function LoginPage() {
const { user, login } = useAuth()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
if (user) return <Navigate to="/rooms" replace />
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError(null)
setSubmitting(true)
try {
await login(username, password)
navigate('/rooms')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Something went wrong')
} finally {
setSubmitting(false)
}
}
return (
<div style={{ maxWidth: 360, margin: '4rem auto' }}>
<h1>KeepItTalking</h1>
<p style={{ color: '#888' }}>This is an invite-only site. Ask an admin for an account.</p>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<label>
Username or email
<input value={username} onChange={(e) => setUsername(e.target.value)} required />
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit" disabled={submitting}>
Log in
</button>
</form>
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { createRoom, joinRoom, listRooms } from '../api/rooms'
import { RoomListItem } from '../components/RoomListItem'
import { useAuth } from '../context/AuthContext'
import type { RoomListItem as RoomListItemType } from '../types'
export function RoomListPage() {
const { user, logout } = useAuth()
const navigate = useNavigate()
const [rooms, setRooms] = useState<RoomListItemType[]>([])
const [newRoomName, setNewRoomName] = useState('')
const [joiningId, setJoiningId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function refresh() {
setRooms(await listRooms())
}
useEffect(() => {
refresh().catch((err) => setError(String(err)))
}, [])
async function handleCreate(e: FormEvent) {
e.preventDefault()
const name = newRoomName.trim()
if (!name) return
setError(null)
try {
await createRoom(name)
setNewRoomName('')
await refresh()
} catch (err) {
setError(String(err))
}
}
async function handleJoin(roomId: string) {
setJoiningId(roomId)
setError(null)
try {
await joinRoom(roomId)
await refresh()
navigate(`/rooms/${roomId}`)
} catch (err) {
setError(String(err))
} finally {
setJoiningId(null)
}
}
return (
<div style={{ maxWidth: 480, margin: '2rem auto' }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1>Rooms</h1>
<div>
<span style={{ marginRight: '1rem' }}>{user?.username}</span>
<button onClick={() => logout()}>Log out</button>
</div>
</header>
<form onSubmit={handleCreate} style={{ display: 'flex', gap: '0.5rem', margin: '1rem 0' }}>
<input
value={newRoomName}
onChange={(e) => setNewRoomName(e.target.value)}
placeholder="New room name"
/>
<button type="submit">Create</button>
</form>
{error && <p style={{ color: 'red' }}>{error}</p>}
<ul style={{ listStyle: 'none', padding: 0 }}>
{rooms.map((room) => (
<RoomListItem
key={room.id}
room={room}
onJoin={handleJoin}
joining={joiningId === room.id}
/>
))}
</ul>
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
export interface User {
id: string
username: string
email: string
is_bot: boolean
is_site_admin: boolean
created_at: string
}
export interface Room {
id: string
name: string
description: string | null
is_private: boolean
owner_id: string
created_at: string
}
export interface RoomListItem extends Room {
is_member: boolean
}
export interface Message {
id: string
room_id: string
user_id: string
content: string
created_at: string
}
export interface ChatMessageEnvelope {
type: 'message'
id: string
room_id: string
user_id: string
username: string
content: string
created_at: string
}
export interface ChatJoinedEnvelope {
type: 'joined'
room_id: string
}
export interface ChatErrorEnvelope {
type: 'error'
detail: string
}
export type ServerEnvelope = ChatMessageEnvelope | ChatJoinedEnvelope | ChatErrorEnvelope
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ServerEnvelope } from '../types'
interface UseChatSocketOptions {
roomId: string
onMessage: (envelope: ServerEnvelope) => void
onUnauthenticated: () => void
}
export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatSocketOptions) {
const socketRef = useRef<WebSocket | null>(null)
const [connected, setConnected] = useState(false)
const onMessageRef = useRef(onMessage)
onMessageRef.current = onMessage
const onUnauthenticatedRef = useRef(onUnauthenticated)
onUnauthenticatedRef.current = onUnauthenticated
useEffect(() => {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${protocol}://${location.host}/ws/chat`)
socketRef.current = ws
ws.onopen = () => {
setConnected(true)
ws.send(JSON.stringify({ type: 'join', room_id: roomId }))
}
ws.onmessage = (event) => {
onMessageRef.current(JSON.parse(event.data) as ServerEnvelope)
}
ws.onclose = (event) => {
setConnected(false)
if (event.code === 4401) {
onUnauthenticatedRef.current()
}
}
return () => {
ws.close()
socketRef.current = null
}
}, [roomId])
const send = useCallback((content: string) => {
const ws = socketRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'message', room_id: roomId, content }))
}, [roomId])
return { connected, send }
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+52
View File
@@ -0,0 +1,52 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'KeepItTalking',
short_name: 'Talking',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#111111',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
{
src: '/icons/icon-512-maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
},
workbox: {
navigateFallbackDenylist: [/^\/api/, /^\/ws/],
runtimeCaching: [
{
urlPattern: /^\/api\//,
handler: 'NetworkOnly',
},
],
},
}),
],
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
})