Skip to content

Commit f66bf67

Browse files
authored
fix(api): wire suppression labels, fix commit ordering, harden proxy and CORS (#64)
Closes the remaining correctness findings from the backend audit. - suppression_labels was stored, exposed via the API and editable in the UI, but handle_pull_request_opened never read it: configuring labels had zero effect. Now read (case-insensitively) before any session is created. - The PR comment was posted while the session row was still uncommitted, so a commit failure left a public link to a session that never existed. It also held a pool connection across ~40s of GitHub I/O. The session is committed before anything is announced. - uvicorn trusted X-Forwarded-* from 127.0.0.1 only, so behind Traefik every client shared the proxy IP and all rate limits collapsed into one global bucket — a single client could lock everyone out of login. - The pool was sized 20+10 per worker against a stock PostgreSQL: 4 workers could ask for 120 of 100 connections. Now configurable, defaulting to 10+5. - Unhandled exceptions returned a 500 without CORS headers (the handler runs outside the middleware stack), so real errors reached the browser as opaque CORS failures. - get_current_user accepted ?access_token= on every route, leaking JWTs into proxy logs and browser history. Split into CurrentUser (header only) and StreamUser (header or query), the latter used by the SSE endpoint alone. 363 tests pass, coverage 84%, ruff and mypy clean.
1 parent 9ce419f commit f66bf67

13 files changed

Lines changed: 348 additions & 175 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ infra/
4545
## Key Patterns
4646

4747
- **App factory**: `helprs.main:create_app()` — module-level `_lifespan` owns the engine and background loops via `AsyncExitStack`: each resource registers its cleanup at acquisition, teardown runs LIFO (cancel loops → drain replay tasks → stop containers → clear factory → dispose engine)
48-
- **Flat modules** (identity, installation, webhook, container): `router.py`, `service.py`, `models.py`, `schemas.py`
49-
- **Container orchestration**: `container` module manages ephemeral Docker lifecycle, credential injection, result relay
48+
- **Layered modules**: each domain module is `router.py` (thin — validate, call one use case, shape the response) → `service.py` (use cases, no SQL, no HTTP) → `repository.py` (every query, including the soft-delete predicate) → boundary modules for external systems (`github.py`, `anthropic.py`, `docker_client.py`), all returning typed objects rather than dicts. `container` additionally splits `streaming.py` (SSE pipeline) and `cleanup.py` (reaping) out of the service.
49+
- **Container orchestration**: `container` module manages ephemeral Docker lifecycle, credential injection, result relay. `finalize_session()` (mark completed → scorecard → PR comment) is deliberately detached from the HTTP request, so a client disconnect cannot leave a session stuck RUNNING.
5050
- **Skills as agents**: each skill is a self-contained folder with workflow definitions, mounted into containers
5151
- **SSE passthrough**: backend relays container output to frontend (no AI response generation in backend)
5252
- **Conversation UI**: frontend renders session output as a structured conversation with markdown (react-markdown + remark-gfm), syntax highlighting (shiki with JS regex engine), and diff coloring. Components: `ConversationOutput` (scroll container) -> `MessageBlock` (role dispatch) -> `MarkdownContent` / `CodeBlock`. Data flows as `StreamMessage[]` (structured content blocks) instead of flat text lines.
@@ -57,7 +57,7 @@ infra/
5757
- **API prefix**: all routes under `/api/v1`
5858
- **Admin panel**: SQLAdmin at `/admin`, configured in `admin/views.py`
5959
- **Dashboard**: user-facing installation management at `/installations` -- installation list, session history, session replay. Authenticated users redirect from `/` to `/installations`. SQLAdmin remains at `/admin` as superadmin escape hatch.
60-
- **Cross-module queries**: installation module queries `ContainerSession` model directly (inline import in service functions) for session counts and lists. This avoids circular imports while keeping the API surface on the installation router.
60+
- **Cross-module queries**: a module never writes SQL over another module's tables. `container/repository.py` owns every `ContainerSession` query, including the aggregates the identity dashboard and the installation router consume.
6161
- **Auth on all REST routes**: identity and installation routers use `Depends(get_current_user)`, container router uses it too. The webhook handler bypasses REST routes entirely — it calls `create_session()` directly (DB record only, no container start). Container start happens when the authenticated frontend calls the REST endpoint.
6262
- **Production env validation**: `Settings` has a `model_validator` that enforces non-empty secrets when `ENVIRONMENT=production`. Tests use `ENVIRONMENT=test` to skip this.
6363
- **Graceful lifecycle**: lifespan reconciles stale RUNNING/PENDING sessions on boot (marks FAILED), and stops all running containers on shutdown. Periodic cleanup uses configurable `CONTAINER_TTL_SECONDS` from settings.
@@ -121,11 +121,9 @@ Key additions for production: `ENVIRONMENT=production`, `ADMIN_PASSWORD`, `CORS_
121121
- **Worktrees and node_modules**: `npm install` must be run in each worktree separately — `node_modules` aren't shared from the main tree
122122
- **Shallow clone + `gh pr checkout --detach`**: `gh pr checkout` (without `--detach`) fails on `--depth=1` clones because git can't set up tracking branches from shallow refs. Always use `--detach` — containers don't need tracking branches, just files on disk.
123123
- **Installation IDs in URLs**: frontend routes (`/installations/:id`) use `github_installation_id` (integer, e.g. `123093268`), NOT the internal UUID. All API endpoints (installation AND container session creation) expect the GitHub integer ID. The service layer resolves to internal UUID via `get_installation_by_github_id()`.
124-
- **Fast-failing container race**: if a container exits before the SSE stream fully drains, the client may disconnect before `mark_completed()` runs, leaving the session stuck as RUNNING with 0 persisted events. The 5-minute cleanup task marks these as TIMEOUT. Root cause: generator cancellation on client disconnect skips the post-stream `mark_completed` call in `_event_stream()`.
125-
- **Flaky dispatcher tests**: `test_issues_opened_is_ignored_and_logged` and `test_pull_request_closed_is_ignored` fail when run as part of the full suite due to structlog `configure_logging()` state contamination from `create_app()` in earlier tests. They pass in isolation.
126124
- **Multi-worker background tasks**: with `--workers N`, each uvicorn worker runs its own lifespan (webhook reaper + container cleanup). Both are idempotent: reaper uses atomic row-level claim (`mark_processing`), cleanup suppresses double-stop exceptions.
127125
- **Run migrations after checkout**: `docker exec helprs-api-1 uv run alembic current` vs `alembic heads` — if they differ, run `make migrate`. Missing columns cause 500s that surface as browser CORS errors (response lacks CORS headers on unhandled exceptions).
128-
- **API rebuild invalidates tokens**: `docker compose up --build api` may regenerate SECRET_KEY, invalidating all JWTs and refresh cookies. Re-authenticate after API restarts.
126+
- **API rebuild invalidates tokens**: re-authenticate after an API restart if the deploy changed `SECRET_KEY`. Nothing in the code generates one — `Settings.SECRET_KEY` is required and the app refuses to boot without it.
129127
- **Coolify `--project-directory`**: Coolify sets `--project-directory` to the repo root, not the compose file location. Relative paths in the compose (`context`, `volumes`) must be relative to the repo root (`./apps/api`, not `../../apps/api`).
130128
- **Coolify domain persistence**: Domains set in the Coolify UI may be cleared on redeploy/reload. Verify after each deploy. If persistent issues, add Traefik labels directly in the compose.
131129
- **`.dockerignore` vs `pyproject.toml`**: `apps/api/.dockerignore` excludes `*.md` but `pyproject.toml` references `readme = "README.md"``!README.md` exception is required in `.dockerignore` or `uv sync` fails.

apps/api/src/helprs/core/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ class Settings(BaseSettings):
4646
# Container orchestration
4747
CONTAINER_TTL_SECONDS: int = 900 # 15 minutes
4848

49+
# Database pool, per uvicorn worker. workers x (size + overflow) must stay
50+
# below the server's max_connections.
51+
DB_POOL_SIZE: int = 10
52+
DB_MAX_OVERFLOW: int = 5
53+
4954
# Environment
5055
ENVIRONMENT: str = "development"
5156

apps/api/src/helprs/core/database.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
from datetime import datetime
77

88
from sqlalchemy import DateTime, func
9-
from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncSession, async_sessionmaker, create_async_engine
9+
from sqlalchemy.ext.asyncio import (
10+
AsyncAttrs,
11+
AsyncEngine,
12+
AsyncSession,
13+
async_sessionmaker,
14+
create_async_engine,
15+
)
1016
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
1117

1218
from helprs.core.config import get_settings
@@ -85,15 +91,21 @@ class Base(AsyncAttrs, DeclarativeBase):
8591
)
8692

8793

88-
def create_engine():
89-
"""Create async SQLAlchemy engine from settings."""
94+
def create_engine() -> AsyncEngine:
95+
"""Create the async engine from settings.
96+
97+
Pool sizing is a per-worker budget: every uvicorn worker builds its own
98+
engine, so ``workers x (pool_size + max_overflow)`` must stay under the
99+
server's ``max_connections``. The defaults are sized for four workers
100+
against a stock PostgreSQL (4 x 15 = 60 of 100).
101+
"""
90102
settings = get_settings()
91103
return create_async_engine(
92104
settings.DATABASE_URL,
93105
echo=False,
94106
pool_pre_ping=True,
95-
pool_size=20,
96-
max_overflow=10,
107+
pool_size=settings.DB_POOL_SIZE,
108+
max_overflow=settings.DB_MAX_OVERFLOW,
97109
pool_recycle=3600,
98110
)
99111

apps/api/src/helprs/core/dependencies.py

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -33,33 +33,8 @@ async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
3333
DbSession = Annotated[AsyncSession, Depends(get_db)]
3434

3535

36-
async def get_current_user(
37-
request: Request,
38-
session: DbSession,
39-
settings: GetSettings,
40-
) -> GitHubUser:
41-
"""Extract and validate Bearer token, return authenticated GitHubUser.
42-
43-
Token sources, in priority order:
44-
45-
1. ``Authorization: Bearer <token>`` header — preferred for all
46-
normal API calls (apiFetch sets it).
47-
2. ``?access_token=<token>`` query parameter — fallback used by SSE
48-
endpoints. ``EventSource`` cannot set custom request headers, so
49-
the SSE caller must put the JWT in the URL. The query-param path
50-
was added 2026-04-11 alongside the Story 3-3 SSE manual-QA fix.
51-
Trade-off: query params land in access logs and browser history,
52-
which is acceptable for a 30-min JWT but not ideal — preferred
53-
long-term solution is fetch+ReadableStream (deferred).
54-
"""
55-
auth_header = request.headers.get("Authorization")
56-
if auth_header and auth_header.startswith("Bearer "):
57-
token = auth_header.removeprefix("Bearer ")
58-
else:
59-
token = request.query_params.get("access_token") or ""
60-
if not token:
61-
raise UnauthorizedError("Missing or invalid Authorization header")
62-
36+
async def _authenticate(request: Request, session: AsyncSession, settings: Settings, token: str) -> GitHubUser:
37+
"""Resolve a bearer token to the user it belongs to."""
6338
try:
6439
payload = decode_access_token(token, settings.SECRET_KEY)
6540
except JWTError as e:
@@ -85,6 +60,35 @@ async def get_current_user(
8560
return user
8661

8762

63+
def _bearer_token(request: Request) -> str:
64+
auth_header = request.headers.get("Authorization", "")
65+
if not auth_header.startswith("Bearer "):
66+
raise UnauthorizedError("Missing or invalid Authorization header")
67+
return auth_header.removeprefix("Bearer ")
68+
69+
70+
async def get_current_user(request: Request, session: DbSession, settings: GetSettings) -> GitHubUser:
71+
"""Authenticate from the ``Authorization`` header."""
72+
return await _authenticate(request, session, settings, _bearer_token(request))
73+
74+
75+
async def get_current_user_for_stream(request: Request, session: DbSession, settings: GetSettings) -> GitHubUser:
76+
"""Authenticate an SSE request, allowing the token in the query string.
77+
78+
``EventSource`` cannot set request headers, so a browser opening a stream
79+
has nowhere else to put the JWT. The cost is real — query strings land in
80+
proxy logs, browser history and ``Referer`` — so this is granted to the
81+
streaming endpoints only, never to the whole API.
82+
"""
83+
token = request.headers.get("Authorization", "").removeprefix("Bearer ") or request.query_params.get(
84+
"access_token", ""
85+
)
86+
if not token:
87+
raise UnauthorizedError("Missing or invalid Authorization header")
88+
return await _authenticate(request, session, settings, token)
89+
90+
8891
# Routes take `user: CurrentUser` rather than `user=Depends(get_current_user)`:
8992
# the Annotated form is a real type for the checker and does not trip B008.
9093
CurrentUser = Annotated[GitHubUser, Depends(get_current_user)]
94+
StreamUser = Annotated[GitHubUser, Depends(get_current_user_for_stream)]

apps/api/src/helprs/main.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,27 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
198198

199199

200200
async def _handle_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
201+
"""Return a 500 the browser can actually read.
202+
203+
A handler registered for ``Exception`` runs inside Starlette's
204+
ServerErrorMiddleware, which sits *outside* the user middleware stack — so
205+
this response never passes through CORSMiddleware. Without the header
206+
below, an unhandled error reaches the frontend as an opaque CORS failure
207+
instead of the real status.
208+
"""
201209
logger.exception("unhandled_exception", path=request.url.path)
210+
211+
headers: dict[str, str] = {}
212+
origin = request.headers.get("origin")
213+
if origin and origin in get_settings().CORS_ORIGINS:
214+
headers["Access-Control-Allow-Origin"] = origin
215+
headers["Access-Control-Allow-Credentials"] = "true"
216+
headers["Vary"] = "Origin"
217+
202218
return JSONResponse(
203219
status_code=500,
204220
content={"error": "internal_error", "message": "An unexpected error occurred"},
221+
headers=headers,
205222
)
206223

207224

apps/api/src/helprs/modules/container/router.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from fastapi import APIRouter, Request, Response
1313
from fastapi.responses import StreamingResponse
1414

15-
from helprs.core.dependencies import CurrentUser, DbSession, GetSettings
15+
from helprs.core.dependencies import CurrentUser, DbSession, GetSettings, StreamUser
1616
from helprs.core.exceptions import ConflictError, NotFoundError
1717
from helprs.core.middleware import limiter
1818
from helprs.core.security import fernet_decrypt
@@ -153,7 +153,7 @@ async def stream_container_output(
153153
request: Request,
154154
db: DbSession,
155155
settings: GetSettings,
156-
user: CurrentUser,
156+
user: StreamUser,
157157
offset: int = 0,
158158
) -> StreamingResponse:
159159
"""Relay the container's output as Server-Sent Events."""

apps/api/src/helprs/modules/webhook/handlers.py

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import structlog
44
from sqlalchemy.ext.asyncio import AsyncSession
55

6+
from helprs.core.config import get_settings
7+
from helprs.modules.container.service import create_session
68
from helprs.modules.installation.service import (
79
create_installation_from_webhook,
810
get_installation_by_github_id,
@@ -15,6 +17,9 @@
1517

1618
logger = structlog.get_logger()
1719

20+
# Skill run for PRs that arrive through a webhook rather than a user choice.
21+
DEFAULT_SKILL = "challenge-me"
22+
1823

1924
def _extract_installation_id(payload: dict) -> int:
2025
"""Extract installation ID from webhook payload, raising ValueError on missing fields."""
@@ -82,28 +87,22 @@ async def handle_installation_unsuspended(payload: dict, session: AsyncSession)
8287
)
8388

8489

85-
async def handle_pull_request_opened(payload: dict, session: AsyncSession) -> None:
86-
"""Handle pull_request.opened webhook event.
90+
def _pr_label_names(pr: dict) -> set[str]:
91+
"""Lowercased label names on the pull request."""
92+
return {label["name"].lower() for label in pr.get("labels", []) if isinstance(label, dict) and label.get("name")}
8793

88-
Creates a container session with the default skill (challenge-me) and posts
89-
a PR comment with a link to the session.
90-
"""
91-
from helprs.core.config import get_settings
92-
from helprs.modules.container.service import create_session
9394

95+
async def handle_pull_request_opened(payload: dict, session: AsyncSession) -> None:
96+
"""Create a session for a newly opened PR and announce it in a comment."""
9497
github_id = _extract_installation_id(payload)
9598
installation = await get_installation_by_github_id(session, github_id)
9699
if installation is None:
97-
await logger.awarning(
98-
"webhook_pr_opened_installation_not_found",
99-
github_installation_id=github_id,
100-
)
100+
await logger.awarning("webhook_pr_opened_installation_not_found", github_installation_id=github_id)
101101
return
102102

103103
pr = payload.get("pull_request") or {}
104104
pr_number = pr.get("number")
105-
repo = payload.get("repository") or {}
106-
repo_full_name = repo.get("full_name")
105+
repo_full_name = (payload.get("repository") or {}).get("full_name")
107106

108107
if not pr_number or not repo_full_name:
109108
await logger.awarning(
@@ -113,42 +112,58 @@ async def handle_pull_request_opened(payload: dict, session: AsyncSession) -> No
113112
)
114113
return
115114

115+
suppressed_by = _pr_label_names(pr) & {label.lower() for label in installation.suppression_labels or []}
116+
if suppressed_by:
117+
await logger.ainfo(
118+
"webhook_pr_opened_suppressed",
119+
github_installation_id=github_id,
120+
repo=repo_full_name,
121+
pr=pr_number,
122+
labels=sorted(suppressed_by),
123+
)
124+
return
125+
116126
cs = await create_session(
117127
db=session,
118128
installation_id=installation.id,
119129
pr_number=pr_number,
120130
repo_full_name=repo_full_name,
121-
skill_name="challenge-me",
131+
skill_name=DEFAULT_SKILL,
132+
)
133+
134+
# Commit before announcing. Posting the comment first would risk a public
135+
# link to a session row that a later commit failure never created, and it
136+
# would hold this connection open across up to ~40s of GitHub I/O.
137+
await session.commit()
138+
139+
await logger.ainfo(
140+
"webhook_pr_session_created",
141+
session_id=str(cs.id),
142+
repo=repo_full_name,
143+
pr=pr_number,
122144
)
123145

146+
await _announce_session(installation, cs.id, repo_full_name, pr_number)
147+
148+
149+
async def _announce_session(installation, session_id, repo_full_name: str, pr_number: int) -> None:
150+
"""Post the session link on the PR. Best effort: never fails the event."""
124151
settings = get_settings()
125152
owner, repo_name = repo_full_name.split("/", 1)
153+
session_path = f"/session/{installation.github_installation_id}/{repo_full_name}/{pr_number}"
126154

127155
try:
128156
token = await mint_installation_token(installation.github_installation_id, settings)
129-
session_path = f"/session/{installation.github_installation_id}/{repo_full_name}/{pr_number}"
130-
comment_body = (
131-
f"**helPRs** session created for this PR.\n\n"
132-
f"Skill: `challenge-me` | "
133-
f"[Open session]({settings.APP_BASE_URL}{session_path})"
134-
)
135157
await post_pr_comment_with_retry(
136158
owner=owner,
137159
repo=repo_name,
138160
pr_number=pr_number,
139-
body=comment_body,
161+
body=(
162+
f"**helPRs** session created for this PR.\n\n"
163+
f"Skill: `{DEFAULT_SKILL}` | "
164+
f"[Open session]({settings.APP_BASE_URL}{session_path})"
165+
),
140166
installation_token=token,
141167
)
142168
except Exception:
143-
# PR comment is best-effort — never block session creation.
144-
await logger.aexception(
145-
"webhook_pr_comment_failed",
146-
session_id=str(cs.id),
147-
)
148-
149-
await logger.ainfo(
150-
"webhook_pr_session_created",
151-
session_id=str(cs.id),
152-
repo=repo_full_name,
153-
pr=pr_number,
154-
)
169+
await logger.aexception("webhook_pr_comment_failed", session_id=str(session_id))

0 commit comments

Comments
 (0)