guest-auth 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.11", "3.12"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - name: Set up Python ${{ matrix.python-version }}
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - name: Install
22
+ run: |
23
+ python -m pip install --upgrade pip
24
+ pip install -e ".[dev]"
25
+ - name: Ruff
26
+ run: ruff check src tests
27
+ - name: Pytest
28
+ run: pytest -v
@@ -0,0 +1,39 @@
1
+ name: Release
2
+
3
+ # Publish to PyPI via OpenID Connect "trusted publishing" — no API token.
4
+ # Triggered by pushing a version tag (e.g. `git tag v0.1.1 && git push origin v0.1.1`).
5
+ # The tagged commit's pyproject.toml `version` is what gets published, so bump
6
+ # it in the same commit you tag.
7
+ on:
8
+ push:
9
+ tags:
10
+ - "v*"
11
+
12
+ jobs:
13
+ release:
14
+ runs-on: ubuntu-latest
15
+ permissions:
16
+ contents: read
17
+ id-token: write # required for PyPI trusted publishing (OIDC)
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.12"
24
+
25
+ - name: Install dependencies
26
+ run: |
27
+ python -m pip install --upgrade pip
28
+ pip install -e ".[dev]" build
29
+
30
+ - name: Lint & test (gate the release)
31
+ run: |
32
+ ruff check src tests
33
+ pytest -v
34
+
35
+ - name: Build sdist + wheel
36
+ run: python -m build
37
+
38
+ - name: Publish to PyPI (trusted publishing)
39
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,27 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+ *.egg
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+ .coverage
14
+ .coverage.*
15
+ htmlcov/
16
+ .tox/
17
+
18
+ # Virtualenvs
19
+ .venv/
20
+ venv/
21
+ env/
22
+
23
+ # Editors
24
+ .vscode/
25
+ .idea/
26
+ *.swp
27
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eric Cooper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.4
2
+ Name: guest-auth
3
+ Version: 0.1.1
4
+ Summary: A static-allowlist invite-token gate for pre-production demos and invite-only previews (pure-ASGI middleware for Starlette / FastAPI apps).
5
+ Project-URL: Homepage, https://github.com/ecoop/guest-auth
6
+ Project-URL: Repository, https://github.com/ecoop/guest-auth
7
+ Project-URL: Issues, https://github.com/ecoop/guest-auth/issues
8
+ Author: Eric Cooper
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Eric Cooper
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: asgi,auth,demo,fastapi,invite,starlette
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Framework :: FastAPI
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Topic :: Internet :: WWW/HTTP :: Session
40
+ Classifier: Topic :: Software Development :: Libraries
41
+ Requires-Python: >=3.11
42
+ Requires-Dist: starlette>=0.35
43
+ Provides-Extra: dev
44
+ Requires-Dist: fastapi>=0.100; extra == 'dev'
45
+ Requires-Dist: httpx>=0.27; extra == 'dev'
46
+ Requires-Dist: pytest>=8.0; extra == 'dev'
47
+ Requires-Dist: ruff>=0.6; extra == 'dev'
48
+ Description-Content-Type: text/markdown
49
+
50
+ # guest-auth
51
+
52
+ **Not a replacement for real authentication.** A static-allowlist invite-token gate for pre-production demos and invite-only previews. Pure-ASGI middleware that plugs into any Starlette / FastAPI app in ~5 lines.
53
+
54
+ Give a tester a link like `https://your-app.example.com/?token=tok_abc123`; the middleware validates the token against an allowlist you own, exchanges it for an `httpOnly` cookie, and attaches an identity (`token` + human-readable `recipient` label) to the request via a `ContextVar` that reaches sync endpoints in the threadpool as well.
55
+
56
+ The library was extracted from [Pitchcraft](https://github.com/ecoop/pitchcraft) and is consumed there in production; [Rulebook](https://github.com/ecoop/rulebook) and JobScout are scheduled to adopt it.
57
+
58
+ **Adopting this in a new app?** See [`docs/integration.md`](docs/integration.md) for the DI pattern, the `app_state.py` template, gotchas (init order, pure-ASGI vs `BaseHTTPMiddleware`, ContextVar propagation), and the constructor reference.
59
+
60
+ ---
61
+
62
+ ## What this is NOT
63
+
64
+ Naming a library `*-auth` invites expectations it doesn't meet. To be explicit:
65
+
66
+ - **No password handling, no MFA, no OAuth / OIDC, no account lifecycle.** The credential is an opaque token you generate and hand to a tester.
67
+ - **No token rotation, expiry, revocation-list, or signed cookies.** The cookie is httpOnly + Secure + SameSite=Lax with a 30-day convenience lifetime; revoking access means removing the token from the allowlist and redeploying.
68
+ - **No rate limiting.** Compose one separately (e.g. [`llm-guardrails`](https://github.com/ecoop/llm-guardrails) ships an IP rate limiter).
69
+ - **Not audited for adversarial threat models.** This is a gate to keep pre-production URLs off the open web and attribute per-tester activity, not a substitute for real identity infrastructure. If you're gating production PII or payment flows, use something else.
70
+
71
+ The value the library provides — a well-scoped ASGI middleware that publishes a per-request identity ContextVar that reaches sync endpoints — is genuinely useful and hard to get right (the "pure-ASGI vs `BaseHTTPMiddleware`" trap is subtle). Everything above is deferred, not planned.
72
+
73
+ ---
74
+
75
+ ## Install
76
+
77
+ ```bash
78
+ pip install guest-auth
79
+ ```
80
+
81
+ Requires Python 3.11+. The only runtime dependency is `starlette`, which any ASGI host already has.
82
+
83
+ ---
84
+
85
+ ## Quick example
86
+
87
+ ```python
88
+ from dataclasses import dataclass, field
89
+ from fastapi import FastAPI
90
+ from guest_auth import InviteAuthMiddleware, get_current_guest
91
+
92
+
93
+ @dataclass
94
+ class Settings:
95
+ demo_mode: bool = True
96
+ invite_tokens: dict = field(
97
+ default_factory=lambda: {"tok_abc123": "Jane Tester"}
98
+ )
99
+
100
+
101
+ settings = Settings()
102
+ app = FastAPI()
103
+
104
+
105
+ @app.get("/")
106
+ def home():
107
+ guest = get_current_guest()
108
+ return {"welcome": guest.recipient if guest else "anonymous"}
109
+
110
+
111
+ app.add_middleware(
112
+ InviteAuthMiddleware,
113
+ config=settings,
114
+ # Optional — pre-rendered HTML for the 401 / welcome page.
115
+ # Omit to use the built-in "This site is currently invite-only." body.
116
+ welcome_html="<h1>Preview build</h1><p>Ask jane@example.com for a link.</p>",
117
+ )
118
+ ```
119
+
120
+ Now:
121
+
122
+ - `GET /?token=tok_abc123` → 302 to `/`, sets `guest_session` cookie.
123
+ - `GET /` with the cookie → returns `{"welcome": "Jane Tester"}`.
124
+ - `GET /` without a cookie → 401 with the welcome page.
125
+ - Flip `settings.demo_mode = False` → gate becomes a complete pass-through with no restart.
126
+
127
+ ---
128
+
129
+ ## Core concepts
130
+
131
+ ### `GuestAuthConfig` (Protocol)
132
+
133
+ The middleware takes a `config` object that satisfies:
134
+
135
+ ```python
136
+ class GuestAuthConfig(Protocol):
137
+ demo_mode: bool
138
+ invite_tokens: Mapping[str, str] # token → recipient label
139
+ ```
140
+
141
+ Both attributes are read at request time, so mutating a live `config` instance (a pydantic `BaseSettings`, a dataclass, whatever) takes effect on the next request without rebuilding the middleware.
142
+
143
+ ### `GuestIdentity` + `get_current_guest()`
144
+
145
+ On a successful cookie match, the middleware sets a request-scoped `ContextVar` with `GuestIdentity(token=..., recipient=...)`. Anywhere downstream — sync or async, including code paths in Starlette's threadpool — `get_current_guest()` returns it or `None`.
146
+
147
+ Because the middleware is pure-ASGI (not `BaseHTTPMiddleware`), the `ContextVar` survives into the threadpool that runs `def` (sync) endpoints. See the [integration doc](docs/integration.md#gotchas) for why this matters.
148
+
149
+ ### `PathScopedContextVarMiddleware` (bonus)
150
+
151
+ An adjacent generic that ships in the same package: match a regex against `scope["path"]`, publish an extracted value on a caller-supplied `ContextVar` for the duration of the request. Same pure-ASGI rationale as the auth middleware. Use for `/api/things/{id}/…` style path-scoped ContextVars (session IDs, tenant IDs, whatever).
152
+
153
+ ---
154
+
155
+ ## Development
156
+
157
+ ```bash
158
+ git clone https://github.com/ecoop/guest-auth
159
+ cd guest-auth
160
+ python -m venv .venv && source .venv/bin/activate
161
+ pip install -e ".[dev]"
162
+ pytest
163
+ ruff check src tests
164
+ ```
165
+
166
+ CI runs on Python 3.11 and 3.12 via [GitHub Actions](.github/workflows/ci.yml).
167
+
168
+ ## Versioning
169
+
170
+ Currently `v0.1.1`. Semver from `v1.0.0` onward; anything before is "shipped but pre-stable API — expect breaking changes."
171
+
172
+ ## Contributing
173
+
174
+ Issues and pull requests welcome. For substantive changes, open an issue first — this library has a deliberately small surface and staying small is a feature.
175
+
176
+ ## License
177
+
178
+ MIT. See [LICENSE](LICENSE).
179
+
180
+ ---
181
+
182
+ _Last updated: 2026-08-06_
@@ -0,0 +1,133 @@
1
+ # guest-auth
2
+
3
+ **Not a replacement for real authentication.** A static-allowlist invite-token gate for pre-production demos and invite-only previews. Pure-ASGI middleware that plugs into any Starlette / FastAPI app in ~5 lines.
4
+
5
+ Give a tester a link like `https://your-app.example.com/?token=tok_abc123`; the middleware validates the token against an allowlist you own, exchanges it for an `httpOnly` cookie, and attaches an identity (`token` + human-readable `recipient` label) to the request via a `ContextVar` that reaches sync endpoints in the threadpool as well.
6
+
7
+ The library was extracted from [Pitchcraft](https://github.com/ecoop/pitchcraft) and is consumed there in production; [Rulebook](https://github.com/ecoop/rulebook) and JobScout are scheduled to adopt it.
8
+
9
+ **Adopting this in a new app?** See [`docs/integration.md`](docs/integration.md) for the DI pattern, the `app_state.py` template, gotchas (init order, pure-ASGI vs `BaseHTTPMiddleware`, ContextVar propagation), and the constructor reference.
10
+
11
+ ---
12
+
13
+ ## What this is NOT
14
+
15
+ Naming a library `*-auth` invites expectations it doesn't meet. To be explicit:
16
+
17
+ - **No password handling, no MFA, no OAuth / OIDC, no account lifecycle.** The credential is an opaque token you generate and hand to a tester.
18
+ - **No token rotation, expiry, revocation-list, or signed cookies.** The cookie is httpOnly + Secure + SameSite=Lax with a 30-day convenience lifetime; revoking access means removing the token from the allowlist and redeploying.
19
+ - **No rate limiting.** Compose one separately (e.g. [`llm-guardrails`](https://github.com/ecoop/llm-guardrails) ships an IP rate limiter).
20
+ - **Not audited for adversarial threat models.** This is a gate to keep pre-production URLs off the open web and attribute per-tester activity, not a substitute for real identity infrastructure. If you're gating production PII or payment flows, use something else.
21
+
22
+ The value the library provides — a well-scoped ASGI middleware that publishes a per-request identity ContextVar that reaches sync endpoints — is genuinely useful and hard to get right (the "pure-ASGI vs `BaseHTTPMiddleware`" trap is subtle). Everything above is deferred, not planned.
23
+
24
+ ---
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install guest-auth
30
+ ```
31
+
32
+ Requires Python 3.11+. The only runtime dependency is `starlette`, which any ASGI host already has.
33
+
34
+ ---
35
+
36
+ ## Quick example
37
+
38
+ ```python
39
+ from dataclasses import dataclass, field
40
+ from fastapi import FastAPI
41
+ from guest_auth import InviteAuthMiddleware, get_current_guest
42
+
43
+
44
+ @dataclass
45
+ class Settings:
46
+ demo_mode: bool = True
47
+ invite_tokens: dict = field(
48
+ default_factory=lambda: {"tok_abc123": "Jane Tester"}
49
+ )
50
+
51
+
52
+ settings = Settings()
53
+ app = FastAPI()
54
+
55
+
56
+ @app.get("/")
57
+ def home():
58
+ guest = get_current_guest()
59
+ return {"welcome": guest.recipient if guest else "anonymous"}
60
+
61
+
62
+ app.add_middleware(
63
+ InviteAuthMiddleware,
64
+ config=settings,
65
+ # Optional — pre-rendered HTML for the 401 / welcome page.
66
+ # Omit to use the built-in "This site is currently invite-only." body.
67
+ welcome_html="<h1>Preview build</h1><p>Ask jane@example.com for a link.</p>",
68
+ )
69
+ ```
70
+
71
+ Now:
72
+
73
+ - `GET /?token=tok_abc123` → 302 to `/`, sets `guest_session` cookie.
74
+ - `GET /` with the cookie → returns `{"welcome": "Jane Tester"}`.
75
+ - `GET /` without a cookie → 401 with the welcome page.
76
+ - Flip `settings.demo_mode = False` → gate becomes a complete pass-through with no restart.
77
+
78
+ ---
79
+
80
+ ## Core concepts
81
+
82
+ ### `GuestAuthConfig` (Protocol)
83
+
84
+ The middleware takes a `config` object that satisfies:
85
+
86
+ ```python
87
+ class GuestAuthConfig(Protocol):
88
+ demo_mode: bool
89
+ invite_tokens: Mapping[str, str] # token → recipient label
90
+ ```
91
+
92
+ Both attributes are read at request time, so mutating a live `config` instance (a pydantic `BaseSettings`, a dataclass, whatever) takes effect on the next request without rebuilding the middleware.
93
+
94
+ ### `GuestIdentity` + `get_current_guest()`
95
+
96
+ On a successful cookie match, the middleware sets a request-scoped `ContextVar` with `GuestIdentity(token=..., recipient=...)`. Anywhere downstream — sync or async, including code paths in Starlette's threadpool — `get_current_guest()` returns it or `None`.
97
+
98
+ Because the middleware is pure-ASGI (not `BaseHTTPMiddleware`), the `ContextVar` survives into the threadpool that runs `def` (sync) endpoints. See the [integration doc](docs/integration.md#gotchas) for why this matters.
99
+
100
+ ### `PathScopedContextVarMiddleware` (bonus)
101
+
102
+ An adjacent generic that ships in the same package: match a regex against `scope["path"]`, publish an extracted value on a caller-supplied `ContextVar` for the duration of the request. Same pure-ASGI rationale as the auth middleware. Use for `/api/things/{id}/…` style path-scoped ContextVars (session IDs, tenant IDs, whatever).
103
+
104
+ ---
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ git clone https://github.com/ecoop/guest-auth
110
+ cd guest-auth
111
+ python -m venv .venv && source .venv/bin/activate
112
+ pip install -e ".[dev]"
113
+ pytest
114
+ ruff check src tests
115
+ ```
116
+
117
+ CI runs on Python 3.11 and 3.12 via [GitHub Actions](.github/workflows/ci.yml).
118
+
119
+ ## Versioning
120
+
121
+ Currently `v0.1.1`. Semver from `v1.0.0` onward; anything before is "shipped but pre-stable API — expect breaking changes."
122
+
123
+ ## Contributing
124
+
125
+ Issues and pull requests welcome. For substantive changes, open an issue first — this library has a deliberately small surface and staying small is a feature.
126
+
127
+ ## License
128
+
129
+ MIT. See [LICENSE](LICENSE).
130
+
131
+ ---
132
+
133
+ _Last updated: 2026-08-06_
@@ -0,0 +1,201 @@
1
+ # Integration Guide
2
+
3
+ How to adopt `guest-auth` in a Python ASGI application. This is the practical companion to the [README](../README.md) — the README explains *what* the library does; this doc explains *how* to wire it into your app.
4
+
5
+ **Reference implementation:** [Pitchcraft](https://github.com/ecoop/pitchcraft) consumes this library in production. Its [`app_state.py`](https://github.com/ecoop/pitchcraft/blob/main/app_state.py) is the canonical adoption pattern; the file pointers throughout this doc are all in that repo.
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install guest-auth
13
+ ```
14
+
15
+ Requires Python 3.11+. The only runtime dependency is `starlette` (which every ASGI host already has), so the install stays lean.
16
+
17
+ ---
18
+
19
+ ## The dependency-injection pattern
20
+
21
+ The library **takes zero configuration from the host app.** The `InviteAuthMiddleware` constructor takes explicit arguments; there are no module singletons inside the library, no config imports, no environment-variable reads.
22
+
23
+ The host app is responsible for:
24
+
25
+ 1. Reading its own config (which env vars, which allowlist source).
26
+ 2. Constructing the middleware at app-factory time with those values.
27
+ 3. Exposing anything downstream needs (e.g. `get_current_guest`) through a facade module rather than reaching into `guest_auth` directly if you'd like to keep the seam narrow.
28
+
29
+ Pitchcraft's [`app_state.py`](https://github.com/ecoop/pitchcraft/blob/main/app_state.py) is the working example for the broader "singleton facade" pattern. The auth-specific slice looks like this:
30
+
31
+ ```python
32
+ # app_state.py — the singleton facade
33
+ from typing import Optional
34
+ from starlette.middleware import Middleware
35
+
36
+ from guest_auth import InviteAuthMiddleware
37
+
38
+
39
+ _welcome_html: Optional[str] = None
40
+
41
+
42
+ def initialize(settings) -> None:
43
+ """Called from main.py at startup, before the FastAPI app factory."""
44
+ global _welcome_html
45
+ _welcome_html = _render_welcome_html(settings.welcome_md_path)
46
+
47
+
48
+ def guest_auth_middleware(settings) -> Middleware:
49
+ """Return the middleware ready to slot into ``FastAPI(middleware=[...])``
50
+ or ``app.add_middleware(...)``. Read once at app-factory time.
51
+ """
52
+ return Middleware(
53
+ InviteAuthMiddleware,
54
+ config=settings,
55
+ welcome_html=_welcome_html,
56
+ )
57
+
58
+
59
+ def _render_welcome_html(md_path) -> str:
60
+ """Host-side: render your welcome copy. The library takes pre-rendered
61
+ HTML so it stays free of markdown / filesystem dependencies.
62
+ """
63
+ ...
64
+ ```
65
+
66
+ Then in `main.py`:
67
+
68
+ ```python
69
+ # main.py — order matters
70
+ from config import settings
71
+
72
+ import app_state
73
+ app_state.initialize(settings) # BEFORE the FastAPI app is built
74
+
75
+ from fastapi import FastAPI
76
+ app = FastAPI()
77
+ app.add_middleware(
78
+ InviteAuthMiddleware,
79
+ config=settings,
80
+ welcome_html=app_state._welcome_html,
81
+ )
82
+ ```
83
+
84
+ Consumers of the identity import `get_current_guest` directly — it's a plain function that reads a `ContextVar`, safe to call from anywhere:
85
+
86
+ ```python
87
+ from guest_auth import get_current_guest
88
+
89
+ def some_endpoint():
90
+ guest = get_current_guest()
91
+ if guest is not None:
92
+ log.info("request from %s (%s)", guest.recipient, guest.token[:8])
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Gotchas
98
+
99
+ ### 1. Pure-ASGI is load-bearing — do NOT use `BaseHTTPMiddleware`
100
+
101
+ The middleware is a pure ASGI class (`__call__(scope, receive, send)`), not `BaseHTTPMiddleware` and not `@app.middleware("http")`. This is not a stylistic choice.
102
+
103
+ Both `BaseHTTPMiddleware` and the `@app.middleware` decorator run the downstream app in a **separate anyio task**. Any `ContextVar` set inside their `dispatch` reaches the endpoint *only if the endpoint happens to run in the same task*. FastAPI's sync (`def`) endpoints run in Starlette's threadpool — a different task — and the `ContextVar` propagation there is unreliable.
104
+
105
+ `InviteAuthMiddleware` sets the identity `ContextVar` from the same task the endpoint runs in, so it propagates into the threadpool along with the copy anyio takes when it launches the sync worker. If you find yourself wrapping this middleware in another `BaseHTTPMiddleware` layer, understand that any ContextVars *that layer* sets won't reach sync endpoints — that's a Starlette-level issue independent of this library.
106
+
107
+ The generic sibling `PathScopedContextVarMiddleware` follows the same pattern for the same reason.
108
+
109
+ ### 2. Init order — `initialize()` before FastAPI app-factory
110
+
111
+ If you're using a facade like the one above, call `app_state.initialize(...)` **before** the FastAPI app is constructed and its routers imported. Once routers are imported, decorators have already fired; anything they closed over (config values, middleware factories) is snapshotted.
112
+
113
+ The middleware itself is fine either way — it reads config at request time — but any *rendering* your facade does at `initialize()` (like the welcome HTML) needs to be ready before whoever calls it.
114
+
115
+ ### 3. Config is read at request time, not construction time
116
+
117
+ The middleware reads `config.demo_mode` and `config.invite_tokens` on every request via attribute lookup. Two implications:
118
+
119
+ - **Tests** can `monkeypatch.setattr(settings, "invite_tokens", {"tok_x": "Test"})` on a live app and the next request picks it up.
120
+ - **Production hot-swap**: if your config source is mutable (a reload signal, a file watcher), pushing a new allowlist value into the same instance flips the gate without a restart.
121
+
122
+ If you specifically want construction-time snapshotting instead, wrap your config in an immutable dataclass and pass a fresh instance.
123
+
124
+ ### 4. Cookie name is a module constant, not per-instance
125
+
126
+ `guest_auth.COOKIE_NAME = "guest_session"`. If you run two apps on the same origin and want per-app cookies, you need one of:
127
+
128
+ - Deploy them on different domains / paths so cookies don't collide.
129
+ - Fork the library and change the constant (there is no constructor arg for it in v0.1.0 — that's a candidate for v0.2).
130
+
131
+ ---
132
+
133
+ ## Constructor reference
134
+
135
+ ### `InviteAuthMiddleware`
136
+
137
+ ```python
138
+ InviteAuthMiddleware(
139
+ app, # ASGI app being wrapped
140
+ config, # object exposing demo_mode + invite_tokens
141
+ *,
142
+ welcome_html: str | None = None, # pre-rendered 401 / welcome body
143
+ )
144
+ ```
145
+
146
+ | Arg | Type | Purpose |
147
+ |---|---|---|
148
+ | `app` | `ASGIApp` | Downstream app the middleware wraps. Populated automatically when using `app.add_middleware()`. |
149
+ | `config` | `GuestAuthConfig` | Any object with `demo_mode: bool` and `invite_tokens: Mapping[str, str]`. Attribute-read per request. |
150
+ | `welcome_html` | `str \| None` | Pre-rendered HTML body injected verbatim into the library's page chrome. `None` uses a minimal built-in fallback. |
151
+
152
+ ### `PathScopedContextVarMiddleware`
153
+
154
+ ```python
155
+ PathScopedContextVarMiddleware(
156
+ app,
157
+ *,
158
+ pattern: re.Pattern | str, # matched against scope["path"]
159
+ contextvar: ContextVar[Any], # target var to publish onto
160
+ extractor: Callable[[re.Match], Any] = lambda m: m.group(1),
161
+ )
162
+ ```
163
+
164
+ | Arg | Type | Purpose |
165
+ |---|---|---|
166
+ | `pattern` | `re.Pattern \| str` | Matched with `.match()` — anchor with `^` for a prefix match. String is compiled internally. |
167
+ | `contextvar` | `ContextVar[Any]` | Value stored on match; `None` on non-match. Reset after each request. |
168
+ | `extractor` | `Callable` | How to turn the `re.Match` into the stored value. Default: first capture group. |
169
+
170
+ ### `GuestIdentity`
171
+
172
+ ```python
173
+ @dataclass(frozen=True)
174
+ class GuestIdentity:
175
+ token: str # the credential itself
176
+ recipient: str # human-readable label from the allowlist
177
+ ```
178
+
179
+ ### Module-level constants + functions
180
+
181
+ | Name | Purpose |
182
+ |---|---|
183
+ | `COOKIE_NAME = "guest_session"` | Cookie the middleware sets and reads. |
184
+ | `COOKIE_MAX_AGE = 60 * 60 * 24 * 30` | 30 days, in seconds. Convenience lifetime, not token expiry. |
185
+ | `get_current_guest() -> GuestIdentity \| None` | Read the current request's identity, or `None` outside a request or when demo mode is off. |
186
+
187
+ ---
188
+
189
+ ## Reference implementation: Pitchcraft
190
+
191
+ Files worth skimming, in priority order:
192
+
193
+ 1. [`app_state.py`](https://github.com/ecoop/pitchcraft/blob/main/app_state.py) — singleton facade pattern.
194
+ 2. [`api/main.py`](https://github.com/ecoop/pitchcraft/blob/main/api/main.py) — how the middleware slots into the FastAPI app-factory.
195
+ 3. [`api/observability.py`](https://github.com/ecoop/pitchcraft/blob/main/api/observability.py) — a consumer of `get_current_guest()` in the request path.
196
+
197
+ ---
198
+
199
+ ## Getting help
200
+
201
+ For questions on the adoption pattern that aren't covered here: open an issue on this repo, or point at the Pitchcraft reference files above — they're the working ground truth.