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; stack-frame locals named pin, pin_hash or temporary_pin are scrubbed on top of the SDK's own list

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 GitHub (pinned tag):

pip install "coffeehouse-common @ git+https://github.com/rubencfh/coffeehouse-common.git@v0.9.0"

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.0

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.0
File Size Uploaded
coffeehouse_common-0.21.0.tar.gz 89.5 kB Details

Built distribution (wheel)

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

Total release size: 161.6 kB

Release files / coffeehouse_common-0.21.0.tar.gz

Download URL coffeehouse_common-0.21.0.tar.gz
Size 89.5 kB
Tags Source
SHA-256 checksum
How to use checksums
abe85581b17d8450b82ba9878725dd24ff8e227c19e91c51787b30a4b99147f4
BLAKE2b-256 checksum
How to use checksums
aa0b51ac91c892e0cbb83adc143bd93bf0c15dff7b50eb0de5c85b86c778af2c
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 12, 2026.

Transparency log

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

Download URL coffeehouse_common-0.21.0-py3-none-any.whl
Size 72.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
dece1c8dd5263a54cec7171d44792f4c5b551dfdd857fe2156111189840b9005
BLAKE2b-256 checksum
How to use checksums
debc8ef41a5ab72196476b84da9e1e7e4cb6b91c6d0b71e22a311da07cbee474
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 12, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.21.0 This release

2 release files

0.17.0

2 release files

0.16.0

2 release files

0.15.1

2 release files

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