Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ab12f46
chore: scaffold repository, settings, tooling and CI
lesnik512 Aug 21, 2026
45dc919
chore(lint): ignore CPY001 missing-copyright-notice
lesnik512 Aug 21, 2026
bdb3ea4
feat: add database plumbing, users table, DI container and app factory
lesnik512 Aug 21, 2026
76c1ee2
fix(review): scope DI override reset, prove DI-resolved sessions shar…
lesnik512 Aug 21, 2026
88032d7
feat: add JWT cookie authentication with argon2 password hashing
lesnik512 Aug 21, 2026
850a82c
fix: address JWT auth review findings (cookie security, status codes,…
lesnik512 Aug 21, 2026
3440e74
feat: add chats and chat members with direct-chat upsert
lesnik512 Aug 21, 2026
cd86d59
fix: address Task 4 review findings (chat creation race, error mappin…
lesnik512 Aug 21, 2026
c455b3f
fix: make the direct-chat race fix actually run under test
lesnik512 Aug 21, 2026
8656cff
feat: add messages with idempotent send and cursor pagination
lesnik512 Aug 21, 2026
17ff2e6
fix: address Task 5 review findings (limit validation, idempotency sc…
lesnik512 Aug 21, 2026
9bbe594
fix: scope idempotency_key uniqueness to (chat_id, idempotency_key)
lesnik512 Aug 21, 2026
4b693fb
feat: add author-only message edit and soft delete
lesnik512 Aug 21, 2026
ebfd9c6
fix: gate message edit/delete on chat membership, dedupe authorizatio…
lesnik512 Aug 21, 2026
2d06bc0
test: prove the message-membership check with a differentiating case
lesnik512 Aug 21, 2026
787045d
feat: add read receipts and chat listing with unread counts
lesnik512 Aug 21, 2026
753ccb2
fix: address Task 7 review findings (last_message_id repoint, atomic …
lesnik512 Aug 21, 2026
f2b4d01
docs: add readme, architecture pages and planning scaffolding
lesnik512 Aug 21, 2026
8daaa1e
fix: use glossary vocabulary in readme/summary, wire check-planning i…
lesnik512 Aug 21, 2026
7855617
fix: stop shipping SERVICE_DEBUG=true in the compose stack
lesnik512 Aug 21, 2026
eaa4475
fix: exclude /static and /metrics from the auth boundary
lesnik512 Aug 21, 2026
c10ef04
fix: correct and complete the auth/chats/messages OpenAPI status codes
lesnik512 Aug 21, 2026
0d125f6
fix: route chat listing through the Collection[T].from_models idiom
lesnik512 Aug 21, 2026
9a44a43
docs: drop internal task numbers from shipped comments
lesnik512 Aug 21, 2026
9721e97
test: consolidate API test helpers into tests/api/helpers.py
lesnik512 Aug 21, 2026
bd95d6d
docs: explain the DeclarativeBase.metadata mutation in the source
lesnik512 Aug 21, 2026
8b08d98
test: cover the create_chat/create_message race-recovery guards
lesnik512 Aug 21, 2026
89be63f
test: enforce zero warnings via filterwarnings = ["error"]
lesnik512 Aug 21, 2026
1336554
fix: correct copied LICENSE/pyproject metadata
lesnik512 Aug 21, 2026
c8467c0
fix: declare the planning/index.py coverage exemption explicitly
lesnik512 Aug 21, 2026
5f78702
docs: expand the readme quickstart past just --list
lesnik512 Aug 21, 2026
624f897
fix(ci): align JWT_SECRET with compose so it clears PyJWT's HS256 min…
lesnik512 Aug 21, 2026
fc041a7
docs: list the full anonymous surface in the auth capability page
lesnik512 Aug 21, 2026
b14de88
docs(planning): record the design decisions taken during implementation
lesnik512 Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.git
.venv
.idea
.superpowers
__pycache__
*.pyc
59 changes: 59 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ${{ github.head_ref || github.run_id }}
cancel-in-progress: true

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.2.0
- run: uv python install 3.14
- run: uv python pin 3.14
- run: |
uv sync --all-extras --all-groups --no-install-project
uv run ruff format . --check
uv run ruff check . --no-fix
uv run ty check
uv run python planning/index.py --check

pytest:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: postgres
POSTGRES_PASSWORD: password
POSTGRES_USER: postgres
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.2.0
- run: uv python install 3.14
- run: uv python pin 3.14
- run: |
uv sync --all-extras --all-groups --no-install-project
uv run alembic upgrade head
uv run pytest .
env:
SERVICE_ENVIRONMENT: ci
PYTHONDONTWRITEBYTECODE: 1
PYTHONUNBUFFERED: 1
DB_DSN: postgresql+asyncpg://postgres:password@127.0.0.1/postgres
# Must match docker-compose.yml and stay >= 32 bytes: PyJWT warns below the
# HS256 minimum (RFC 7518 3.2), and filterwarnings = ["error"] makes that fatal.
JWT_SECRET: insecure-ci-secret-do-not-use-in-prod
158 changes: 158 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

`chat-app` was bootstrapped from `litestar-sqlalchemy-template`. It is **not**
the template with new routes — three things below differ from what the
template teaches, and getting any of them wrong by pattern-matching on the
template breaks the transaction model or the DI wiring.

## The three things most likely to be got wrong

1. **`modern-di` 3.x uses `cache=`, not `cache_settings=`.** Providers that
need a finalizer or app-scope caching pass `cache=providers.CacheSettings(finalizer=...)`
(see `app/ioc.py::Database.database_engine`). The template predates this
API; do not copy `cache_settings=` from memory or from an older `modern-di`
example.
2. **Repositories run `auto_commit=False`.** Every `*_repository` provider in
`app/ioc.py` is constructed with `kwargs={"session": ..., "auto_commit":
False}` — the opposite of the template's `auto_commit=True`. A repository
here never commits on its own.
3. **Use cases own the transaction boundary, not repositories.** Every use
case that writes wraps its work in `async with self.transaction:` (a
`db_retry.Transaction`) and calls `await self.transaction.commit()`
explicitly once every write that must land together has been made — e.g.
`CreateMessageUseCase` commits the new message row and the
`chats.last_message_id` update together. This exists because a single
operation can span more than one repository write and they must succeed or
fail as a unit; giving that back to individually auto-committing
repositories would make that impossible. See `architecture/messages.md`
and `architecture/chats.md` for the two hazards this creates around
`Transaction.__aexit__`'s unconditional rollback-on-open-transaction
behavior (returning a loaded ORM object from inside an uncommitted `async
with self.transaction:` block detaches it).

## Commands

Recipes live in the `Justfile` — run `just --list` to see them; this section
only covers what isn't obvious from the recipe names.

Almost everything runs through Docker Compose: the app and Postgres come up
together, and running tests/migrations outside Docker is **not** the
supported path (`just install` and `just lint` are the exceptions — they run
on the host). Inside the container, raw commands look like `uv run pytest
...`, `uv run alembic ...`.

- `just test` cycles the DB (downgrade to `base`, upgrade to `head`) before
pytest and tears the stack down before and after. Pass pytest args through,
e.g. `just test tests/use_cases/test_create_chat.py -k race -x`.
- `just migration "message"` takes a **single positional argument** — not a
`-m` flag — quoted so a multi-word message survives as one token (the
recipe shell-quotes it with `quote()` before handing it to `alembic
revision --autogenerate -m`). It runs against an already-upgraded DB; the
recipe enforces that by upgrading first, so don't run autogen by hand.
- `just lint` runs `eof-fixer`, `ruff format`, `ruff check --fix`, then `ty
check` — this project uses `ty`, not mypy; suppress with `# ty:
ignore[<rule>]` (not `# type: ignore`).
- `just index` prints the planning change/decision listing; `just
check-planning` validates `planning/changes/` and `planning/decisions/`
frontmatter (CI-equivalent check, run before pushing a planning change).

Python is 3.14, dependencies managed by `uv`. The API is exposed on `:8000`.

## Architecture

**Stack**: Litestar + SQLAlchemy 2 (async) + advanced-alchemy + Alembic +
Postgres 17 + Granian (ASGI server) + `modern-di` (IoC) + `lite-bootstrap`
(observability/CORS/Sentry/OTel wiring) + `db-retry` (transaction boundary +
retry decorator).

**Request flow**: `app/api/__main__.py` → `granian` → `app.api.app:build_app`
(factory) → `LitestarBootstrapper` from `lite-bootstrap` wraps a
`litestar.Litestar` with OpenTelemetry (asyncpg + SQLAlchemy instrumentors,
with `AsyncPGInstrumentor(capture_parameters=False)` so argon2 password
hashes bound as INSERT parameters never reach the OTel collector), Sentry,
CORS, Swagger, etc., based on `Settings.api_bootstrapper_config`.
`build_app` also calls `settings.ensure_jwt_secret_is_configured()` first,
which raises at startup if a non-local environment is still running the
default JWT secret.

**Dependency injection** (`app/ioc.py`): one `modern_di.Container` built from
`ALL_GROUPS = [Database, Repositories, UseCases]`, attached via
`modern_di_litestar.ModernDIPlugin`. Route handlers receive use cases as
parameters; each `app/api/endpoints/*.py` module declares them with
`modern_di_litestar.FromDI(...)` (wired centrally in `build_app`'s
`dependencies=` dict) so Litestar resolves them per-request. Provider scopes:
- `Database.database_engine` — app-scoped factory, `cache=` finalizer disposes
the engine.
- `Database.database_session` — request-scoped, finalizer closes the session.
- `Database.transaction` — request-scoped `db_retry.Transaction`, the object
every write-side use case wraps its commit in.
- `Repositories.*` — request-scoped, `auto_commit=False` (see above).
- `UseCases.*` — request-scoped, one class per operation.

**Persistence**: Models inherit `advanced_alchemy.base.BigIntAuditBase` /
`BigIntBase`. `app/database/tables.py` shares metadata with
`orm.DeclarativeBase.metadata` (`METADATA = orm_registry.metadata;
orm.DeclarativeBase.metadata = METADATA`) so Alembic autogen sees everything —
this line mutates a third-party base class at import time; see the comment
above it in the source for why. Repositories are
`SQLAlchemyAsyncRepositoryService[Model]` with a nested
`BaseRepository(SQLAlchemyAsyncRepository[Model])`, same shape as the
template, but every service here is constructed with `auto_commit=False`.

**Test isolation** (`tests/conftest.py`): `db_session` opens a connection,
starts a transaction, then **overrides** `Database.database_engine` in the DI
container to return that connection; `create_session`'s
`join_transaction_mode="create_savepoint"` is what makes every session opened
against it — fixture or route handler — nest as a savepoint instead of
committing past the outer transaction. Teardown rolls the outer transaction
back. `app`/`client` fixtures build the real app and run it through
`httpx.ASGITransport` + `asgi_lifespan.LifespanManager`.
`modern_di_pytest.expose(ioc.Repositories, ioc.UseCases,
container_fixture="request_container")` (`tests/use_cases/conftest.py`)
exposes every repository/use case provider as a same-named pytest fixture —
the template predates this and hand-assembles dependencies instead. Full
detail, including the race-simulation pattern used to test the
concurrent-retry paths without a second real connection, is in
`architecture/testing.md`.

**Migrations**: `migrations/env.py` reads the shared `METADATA` and rewrites
the DSN driver from `postgresql+asyncpg` → `postgresql` (Alembic uses sync
psycopg2). Always run autogen against an upgraded DB — `just migration`
enforces this.

**Settings** (`app/settings.py`): `pydantic_settings.BaseSettings` reads from
env vars (see `docker-compose.yml`). `api_bootstrapper_config` builds the
`LitestarConfig` consumed by `lite-bootstrap`. `jwt_cookie_secure` defaults
`False` for local `http://` development and must be `True` behind HTTPS.

## Conventions

- Routes live in `app/api/endpoints/`, one module per resource (`auth.py`,
`chats.py`, `messages.py`), each exposing its own `ROUTER` (`litestar.Router`,
prefix `/api`). `app/api/app.py::build_app` registers them all via
`route_handlers=[auth_endpoints.ROUTER, chats_endpoints.ROUTER,
messages_endpoints.ROUTER]`. Add a new resource by creating
`app/api/endpoints/<name>.py`, defining handlers + a `ROUTER`, and adding it
to that list plus `build_app`'s `dependencies=` dict for any new use case.
- Use cases live in `app/use_cases/`, one `@dataclasses.dataclass(kw_only=True,
frozen=True, slots=True)` per operation with an async `__call__` decorated
`@db_retry.postgres_retry`. Shared authorization logic that more than one
use case needs (e.g. the author-and-member gate for edit/delete) lives in a
plain module-level function, not a base class — see
`app/use_cases/message_authorization.py`.
- Pydantic schemas in `app/schemas/api.py` use `from_attributes=True` (via
`Base`) so they validate directly from ORM instances
(`schemas.X.model_validate(orm_instance)`). Collection responses go through
`Collection[T].from_models(...)` (e.g. `schemas.Messages`, `schemas.Chats`).
- Domain exceptions (`app/exceptions.py`: `PermissionDeniedError`,
`ValidationError`, `ConflictError`) are registered as handlers in
`build_app`'s `exception_handlers` dict alongside the `advanced_alchemy`
exceptions (`NotFoundError`, `DuplicateKeyError`, `ForeignKeyError`). Full
mapping table and the one deliberate exception (login's `401` via Litestar's
own `NotAuthorizedException`) are in `architecture/messages.md` and
`architecture/auth.md`.
- `ruff` is configured with `select = ["ALL"]` and a line length of 120 —
expect strict lint. Type-check with `ty`; use `# ty: ignore[<rule>]` for
suppressions.
26 changes: 26 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
FROM python:3.14-slim

RUN apt update \
&& apt install -y --no-install-recommends build-essential libpq-dev \
&& apt clean \
&& rm -rf /var/lib/apt/lists/*

COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
RUN useradd --no-create-home --gid root runner

ENV UV_PROJECT_ENVIRONMENT=/code/.venv \
UV_NO_MANAGED_PYTHON=1 \
UV_NO_CACHE=true \
UV_LINK_MODE=copy

WORKDIR /code

COPY pyproject.toml .

RUN uv sync --all-extras --all-groups --no-install-project

COPY . .

RUN chown -R runner:root /code && chmod -R g=u /code

USER runner
41 changes: 41 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
default: install lint build test

down:
docker compose down --remove-orphans

sh:
docker compose run --service-ports api bash

test *args: down && down
docker compose run api sh -c "sleep 1 && uv run alembic downgrade base && uv run alembic upgrade head && uv run pytest {{ args }}"

run:
docker compose run --service-ports api sh -c "sleep 1 && uv run alembic upgrade head && uv run python -m app.api"

migration message: && down
# `message` is a single named parameter, shell-quoted via quote() so a multi-word message
# survives intact - a variadic *args parameter only ever joins tokens with spaces when
# interpolated, losing the quoting boundaries the invoking shell already stripped, so a
# multi-word message would otherwise reach sh -c as several disconnected words.
docker compose run api sh -c "sleep 1 && uv run alembic upgrade head && uv run alembic revision --autogenerate -m {{ quote(message) }}"

build:
docker compose build api

install:
uv lock --upgrade
uv sync --all-extras --all-groups --no-install-project

lint:
uv run eof-fixer .
uv run ruff format .
uv run ruff check . --fix
uv run ty check

# Print the planning change index (flat, newest-first) to stdout.
index:
uv run python planning/index.py

# Validate planning changes + decisions (frontmatter, lanes, spec links); CI runs this.
check-planning:
uv run python planning/index.py --check
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Artur Shiriev

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Loading