Skip to main content

coffeehouse-common

Shared Python package for Coffee House ecosystem FastAPI apps:

  • coffeehouse_common.csrf — CSRFMiddleware subclass (form body + header + safe body replay; the cookie has a lifetime, every refusal is logged with its reason)
  • coffeehouse_common.security_middleware — SecurityHeadersMiddleware (raw ASGI; the HTTPS-only header is switched on per app)
  • coffeehouse_common.session_renewal — SessionRenewalMiddleware (raw ASGI): renews an expired session inside the request that needs it
  • coffeehouse_common.auth_gate — auth_required_handler: the one answer every app gives when a session cannot be renewed
  • coffeehouse_common.logging_config — RequestIdMiddleware (raw ASGI), RequestIdFilter, configure_logging, get_request_id
  • coffeehouse_common.observability — init_sentry(app_slug) Sentry SDK bootstrap; reads SENTRY_DSN from env (no-op when unset), tags events with app_slug, drops 4xx HTTP/CSRF noise, keeps 5xx; events carry no stack-frame local variables (0.21.1: the raw ASGI headers in every frame would otherwise ship session cookies and keys) and no HTTP request body (0.21.2: max_request_body_size="never"; a failed form or JSON POST would otherwise ship names, salaries and notes under any field name), and the names pin, pin_hash, temporary_pin, x-app-key, x_app_key, ch_session and ch_refresh are scrubbed from request headers, data and extras, at any depth, on top of the SDK's own list. The scrubbing matches names only: it does not reach the request's query string, the parameters of a log message, or breadcrumb messages (an INFO log line becomes a breadcrumb), so never log headers, cookies or request bodies and never put a secret in a URL. Data an app attaches itself is sent with only that name scrubbing (set_extra, set_context) or none at all (the text of capture_message), so never attach a person's records to an event. Exception messages are sent verbatim: a pydantic ValidationError quotes the rejected input, so models built from request data should set model_config = ConfigDict(hide_input_in_errors=True)
  • coffeehouse_common.db — create_engine_with_defaults(url, **opts) (pool_pre_ping=True, pool_recycle=300, and since 0.21.2 hide_parameters=True, which drops SQLAlchemy's [parameters: ...] list from exception text and SQL logging; caller args win). It does not touch the database's own message: PostgreSQL still quotes values in a constraint or type error (DETAIL: Key (email)=(...) already exists., DETAIL: Failing row contains (...)), and that text reaches Sentry and the logs and with_db_retry for background workers

v0.3.0+ implements security headers and request ID middleware as raw ASGI (not BaseHTTPMiddleware) so streaming responses are not fully buffered. v0.3.1 sets X-Frame-Options: SAMEORIGIN (same-origin iframes allowed; third-party framing blocked). Logs include a per-request correlation id when configure_logging() + RequestIdMiddleware are used.

Install

From PyPI (exact release; each app pins the version in its own requirements.txt):

pip install "coffeehouse-common==0.21.2"

Local editable install:

pip install -e ../coffeehouse-common

Development

Set up the repo for local development + testing:

git clone https://github.com/rubencfh/coffeehouse-common.git
cd coffeehouse-common
pip install -e ".[testing]"
pip install -r requirements-dev.txt
pre-commit install

Run the full test suite (with coverage, fails under 70%):

pytest

Run the pre-commit hooks against all files (format, lint, secret scan, syntax check):

pre-commit run --all-files

Individual checks:

ruff format --check .          # formatting
ruff check .                   # linting
mypy coffeehouse_common        # type-checking (lenient baseline)
bandit -c pyproject.toml -r coffeehouse_common coffeehouse_testing  # SAST
pip-audit -r requirements-dev.txt                                    # SCA

CI runs the same checks on every push / PR via .github/workflows/ci.yml. See coffeehouse-ecosystem/docs/pipeline.md for the broader pipeline.

Shared test fixtures

The coffeehouse_testing subpackage ships with this repo and provides shared pytest fixtures that consumer apps import in their own tests/conftest.py:

from coffeehouse_testing.jwt_factory import jwt_factory
from coffeehouse_testing.client import authed_client
from coffeehouse_testing.db import postgres_container, clean_db  # noqa: F401
from coffeehouse_testing.auth_stub import make_auth_stub, build_roles_manifest

All heavy dependencies (PyJWT, testcontainers, starlette) are loaded lazily — import the modules you need in tests and the import errors only fire if the underlying package is not installed.

Usage

from coffeehouse_common.csrf import CSRFMiddleware
from coffeehouse_common.logging_config import RequestIdMiddleware, configure_logging
from coffeehouse_common.observability import init_sentry
from coffeehouse_common.security_middleware import SecurityHeadersMiddleware

configure_logging()  # once at startup; attaches RequestIdFilter for %(request_id)s
init_sentry(app_slug="auth")  # no-op if SENTRY_DSN is unset

init_sentry reads SENTRY_DSN, RAILWAY_ENVIRONMENT_NAME (or ENVIRONMENT), and RAILWAY_GIT_COMMIT_SHA from the environment. Errors-only sample rate by default (traces_sample_rate=0.0). 401/403/404/405 responses, 4xx HTTPExceptions, and CSRFErrors are dropped client-side so they don't burn the Sentry quota. 5xx HTTPExceptions and any other exception class are kept. Pass extra_ignored_status_codes= / extra_ignored_exceptions= to extend the filter per app.

Explicit dependency: itsdangerous (CSRF token signing).

Report-table sorting

coffeehouse_common.report_sort (promoted from Grind after Stoply became the second adopter) pairs with coffeehouse-ui's sortable_th macro: parse_sort(request, allowed=..., default_sort=...) validates ?sort=/?dir= against a per-report allowlist; sort_data(rows, sort, dir) sorts a materialized list[dict] in place (Nones last in both directions, case-insensitive strings). Both are re-exported from the package root.

Redirects by route name

coffeehouse_common.redirects (promoted from Cupping) builds every Post/Redirect/Get target from the app's own route table instead of a string:

# app/redirects.py -- one Redirector per app
from typing import Any

from coffeehouse_common.redirects import Redirector


def _app() -> Any:
    from app.server import app  # lazy: server imports the route modules

    return app


redirects = Redirector(_app)

# in a route
return redirects.to("view_thing", fallback="list_things", thing_id=7, success="saved")

to() answers 303 with flash codes in the query string (error=, success=, warning=; a value that is not a code is dropped). The Location comes from Starlette's url_path_for, so it can only be one of the app's own paths. A route name the table does not serve, or a path parameter that is not an int or a string of URL-unreserved characters, falls back to the named fallback route, and to root (/) if that cannot be built either -- with a warning that names parameter keys and types, never values. url_for() is the href-building half and raises instead of falling back. Redirector(..., int_param_suffix="_id") additionally requires every *_id parameter to be an int. Unlike coffeehouse_common.flash (a sentence in a session cookie), this needs no middleware.

Sessions: renewal, and the answer when renewal is not possible

Auth's ch_session cookie lasts 15 minutes and activity does not extend it. Two modules (0.21.0) make that invisible to a person at work and uniform when it cannot be:

from coffeehouse_common.auth_gate import auth_required_handler
from coffeehouse_common.session_renewal import SessionRenewalMiddleware

app.add_exception_handler(AuthRequired, auth_required_handler(build_login_url))

app.add_middleware(RequestIdMiddleware)
app.add_middleware(SecurityHeadersMiddleware, hsts_max_age=31_536_000)
app.add_middleware(CSRFMiddleware, secret=APP_SECRET_KEY, exempt_urls=[...])
# Immediately before CORS, which stays the last registration (= outermost).
app.add_middleware(SessionRenewalMiddleware, auth_url=AUTH_SERVICE_URL)
app.add_middleware(CORSMiddleware, ...)

SessionRenewalMiddleware acts on a request that carries the week-long ch_refresh cookie and whose ch_session is absent, unreadable or within 60 seconds of expiry. It asks Auth (POST {auth_url}/api/session/renew, in a worker thread, at most four at a time, 2 s to connect and 3 s to read), hands the app the renewed session and passes Auth's Set-Cookie through on the same response. So a form saved after 15 minutes is saved.

  • It is registered outside CSRF and inside CORS: the add_middleware call immediately before the CORS one.
  • It never raises and never reads the request body. A refusal (401), an unreachable Auth or a malformed answer leaves the request as it arrived, and the app's own guard answers it. After a network failure nobody asks again for 30 seconds; a refusal starts no such pause.
  • It renews only on a well-formed 200: a token of three base64url segments and a set_cookie whose ch_session value is exactly that token, with no control character. The app runs this request as token and the browser sends the cookie's value from then on, so the two must be one session. Any other 200 counts as Auth being unreachable.
  • It never renews a background poll (X-Background-Request, set by coffeehouse-ui's session.js on timer-fired HTMX requests) or a request with Authorization: Bearer. Only a person's action renews.
  • exp is read without checking the signature, only to decide whether to ask; the app's guard still verifies the token.
  • No cache and no skip list. auth_url empty or None switches it off.
  • A route that raises is answered by Starlette outside every middleware, so a 500 does not carry the renewed cookie; the next request renews again.

auth_required_handler(build_login_url, *, api_response=None) is the exception handler for the app's own AuthRequired. It answers by kind of request and returns the person to the page they were on:

Request Answer Comes back to
page load (GET/HEAD) 302 to login the request URL
HTMX 401 + HX-Redirect HX-Current-URL (same host only)
HTMX background poll 204, empty nothing navigates
any other method 303 to login Referer (same host only), else the app root

api_response(request) runs first, for an app that answers its JSON API in its own shape; return None to fall through. session_expired_response(request, login_url) is the same answer without the exception plumbing.

Log lines. Each names the first path segment (area=/questions), never the path, and never a token:

session renewal: outcome=renewed|refused|unreachable reason=<...> method=<...> area=<...>
session expired: kind=write|htmx method=<...> area=<...>
csrf rejected: reason=cookie_missing|token_missing|mismatch method=<...> area=<...> session=yes|no

session expired is how a lost save is counted: after adoption it is a 303, the same status as a saved one. session=yes means the request carried ch_session or ch_refresh.

The security cookie (csrftoken) is issued with Max-Age of one year; CSRFMiddleware(..., cookie_max_age=None) restores a session cookie; zero or a negative number is refused at start-up, because the browser would discard the cookie at once. A browser that already holds a valid cookie is not sent a new one.

The HTTPS-only header is off by default. SecurityHeadersMiddleware(hsts_max_age=31_536_000) sends Strict-Transport-Security: max-age=31536000 on every response, never with includeSubDomains or preload. A browser keeps the rule for a year: to take it back pass hsts_max_age=0; None only stops sending it.

In an app's tests

from coffeehouse_testing.auth_gate import (
    assert_every_route_requires_session,
    assert_public_paths_are_served,
)
from coffeehouse_testing.session_renewal import assert_renewal_wraps_csrf, fake_renewal

PUBLIC = {"/health", "/csrf-smoke", "/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}


def test_every_route_requires_a_session():
    client = TestClient(app, follow_redirects=False)
    checked = assert_every_route_requires_session(
        client, public_paths=PUBLIC, login_url=f"{AUTH_SERVICE_URL}/login"
    )
    assert len(checked) > 5  # an empty walk would pass everything


def test_public_paths_are_real_routes():
    assert_public_paths_are_served(app, PUBLIC)


def test_renewal_is_registered_where_it_must_be():
    assert_renewal_wraps_csrf(app)


def test_an_expired_session_is_renewed_inside_the_request():
    token = jwt_factory(sub="E-1", apps={"myapp": {"role": "admin"}}, secret=JWT_SECRET)
    client = TestClient(app, follow_redirects=False)
    client.cookies.set("ch_refresh", "any-value")  # no ch_session: it expired
    with fake_renewal("renewed", token=token) as calls:
        assert client.get("/a-guarded-page").status_code == 200
    assert calls == ["any-value"]

The walk sends an anonymous GET to every route the app serves and fails on any that renders. It counts a 404, 405 or 410 as denied (catch-all routes need that), so a route that looks an id up before checking the session passes on its 404: for an app that checks the session inside the handler, the check must come before any lookup. fake_renewal("refused") and fake_renewal("unreachable") produce the other two outcomes; only the HTTP call is replaced, so the test fails when the middleware line is removed.

See CONVENTIONS.md in the coffeehouse-ecosystem repo for middleware order and CSRF patterns.

First-time publish (maintainers)

  1. Create an empty repo on GitHub: rubencfh/coffeehouse-common
  2. From this directory:
git remote add origin https://github.com/rubencfh/coffeehouse-common.git
git push -u origin master
git push origin v0.9.0

Until the repo and tag exist, app Docker builds that pip install from GitHub will fail. For local dev, use editable install (above).

Metadata

Release files for coffeehouse-common 0.21.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for coffeehouse-common 0.21.2
File Size Uploaded Provenance
coffeehouse_common-0.21.2.tar.gz 98.1 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for coffeehouse-common 0.21.2
File Interpreter ABI Platform
coffeehouse_common-0.21.2-py3-none-any.whl Python 3 none any Details

Total release size: 172.7 kB

Release files / coffeehouse_common-0.21.2.tar.gz

Download URL coffeehouse_common-0.21.2.tar.gz
Size 98.1 kB
Tags Source
SHA-256 checksum
How to use checksums
fd8db15c09c7c3f29f17e40a1d65ebc88d984a1d8509ebfe928c307480ecc8cd
BLAKE2b-256 checksum
How to use checksums
7d00e7ffaf8c8aa1352d1a790d297e0ac85efa3a51d591744ef4efdd49e259ce
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.

Transparency log

Release files / coffeehouse_common-0.21.2-py3-none-any.whl

Download URL coffeehouse_common-0.21.2-py3-none-any.whl
Size 74.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
84b71120e50bb7d673faa12d24483ce7bb7ee2296e3d141c432e63b567db1aa0
BLAKE2b-256 checksum
How to use checksums
e6d1595947afadfb53b541c71458af02ed24289b7c8483b8cc535d77563b6061
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.

Transparency log

Security for coffeehouse-common 0.21.2

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for coffeehouse_common-0.21.2.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page