lightweb-adapter-sdk 0.2.2__tar.gz → 0.2.4__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.
Files changed (50) hide show
  1. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/CHANGELOG.md +62 -0
  2. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/PKG-INFO +1 -1
  3. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/testkit.md +84 -1
  4. lightweb_adapter_sdk-0.2.4/src/lightweb_adapter_sdk/auth/__init__.py +42 -0
  5. lightweb_adapter_sdk-0.2.4/src/lightweb_adapter_sdk/auth/errors.py +16 -0
  6. lightweb_adapter_sdk-0.2.4/src/lightweb_adapter_sdk/auth/protocol.py +145 -0
  7. lightweb_adapter_sdk-0.2.4/src/lightweb_adapter_sdk/auth/provider.py +128 -0
  8. lightweb_adapter_sdk-0.2.4/src/lightweb_adapter_sdk/event_consumer/parser.py +119 -0
  9. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/testkit/__init__.py +7 -0
  10. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/testkit/fake_event_source.py +39 -24
  11. lightweb_adapter_sdk-0.2.4/src/lightweb_adapter_sdk/testkit/fake_token_endpoint.py +145 -0
  12. lightweb_adapter_sdk-0.2.2/src/lightweb_adapter_sdk/event_consumer/parser.py +0 -94
  13. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/.github/workflows/ci.yml +0 -0
  14. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/.github/workflows/release.yml +0 -0
  15. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/.gitignore +0 -0
  16. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/LICENSE +0 -0
  17. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/README.md +0 -0
  18. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/conformance-audit.md +0 -0
  19. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/conformance.md +0 -0
  20. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/contract.md +0 -0
  21. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/event-consumer.md +0 -0
  22. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/examples/round-0-prompt.md +0 -0
  23. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/round-prompt-template.md +0 -0
  24. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/supervisor-side-edits.md +0 -0
  25. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/wrap-procedures.md +0 -0
  26. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/pyproject.toml +0 -0
  27. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/__init__.py +0 -0
  28. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/adapter/__init__.py +0 -0
  29. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/__init__.py +0 -0
  30. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/fault_control.py +0 -0
  31. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_apply.py +0 -0
  32. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_capabilities.py +0 -0
  33. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_disable.py +0 -0
  34. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_event_consumer.py +0 -0
  35. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_health.py +0 -0
  36. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_identity_mapping.py +0 -0
  37. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_list_accounts.py +0 -0
  38. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_reconcile.py +0 -0
  39. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/event_consumer/__init__.py +0 -0
  40. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/event_consumer/dedup.py +0 -0
  41. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/event_consumer/jetstream.py +0 -0
  42. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/event_consumer/protocol.py +0 -0
  43. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/py.typed +0 -0
  44. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/reference/__init__.py +0 -0
  45. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/reference/event_consumer.py +0 -0
  46. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/reference/fake_server.py +0 -0
  47. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/reference/fault_control.py +0 -0
  48. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/testkit/fake_entitlements.py +0 -0
  49. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/testkit/structural.py +0 -0
  50. {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/types/__init__.py +0 -0
@@ -8,6 +8,68 @@ Breaking changes are flagged with `**BREAKING:**` at the start of the entry, eve
8
8
 
9
9
  The SDK is in 0.x until its contract has been validated by at least one production adapter. Per [`CONVENTIONS.md`](CONVENTIONS.md) §1.3, breaking changes may land in minor (0.X → 0.(X+1)) releases while in 0.x; pin against a specific minor version.
10
10
 
11
+ ## [0.2.4] - 2026-05-26
12
+
13
+ ### Added
14
+
15
+ - `auth` module: `ClientCredentialsTokenProvider` — a reusable OAuth2
16
+ client-credentials token provider that acquires, caches, and
17
+ refreshes bearer tokens. Takes an **injected** async HTTP client
18
+ (structural `HttpClient` Protocol) rather than importing an HTTP
19
+ library, so the SDK's runtime dependency surface remains
20
+ `pydantic` + `nats-py`. Includes `TokenProvider` Protocol,
21
+ `HttpClient`/`HttpResponse` Protocols, and `TokenAcquisitionError`
22
+ exception. Concurrency-safe: concurrent `get_token()` calls near
23
+ expiry trigger a single re-acquisition under an `asyncio.Lock`.
24
+ - `testkit.FakeTokenEndpoint`: an in-process fake of an OAuth2 token
25
+ endpoint that satisfies the `HttpClient` Protocol structurally. Lets
26
+ tests stage token values, assert caching (call count), exercise
27
+ refresh-before-expiry, and verify clean failure on error responses.
28
+
29
+ ## [0.2.3] - 2026-05-24
30
+
31
+ ### Fixed
32
+
33
+ - `event_consumer.parser.parse_envelope` now reads the platform's
34
+ native event-envelope field names — `event_id`, `event_type`,
35
+ `event_version`, `occurred_at`, `emitted_by` — per
36
+ `libs/events/schemas/entitlements/_envelope.json` and the entitlements
37
+ service's `outbox/envelope.py::build_envelope`. It previously read a
38
+ CloudEvents shape (`id`, `type`, `specversion`, `time`, `source`) that
39
+ the platform never emits, rejecting every real event at `Missing
40
+ required field: id`; an adapter consuming the live bus would have
41
+ nacked/dead-lettered all entitlements events. `member_id` is still
42
+ sourced from `data.member_id`; `subject` is tolerated and ignored (it
43
+ is schema-required and always present, but the consumer hook is
44
+ `data.member_id`, since `subject.id` varies by event type). The
45
+ `EventEnvelope` type is unchanged — this is a parser-internal fix,
46
+ non-breaking. The parser docstring, which had incorrectly described
47
+ the CloudEvents shape as matching the schema, is corrected to the
48
+ native shape. Surfaced by NextCloud D3 consumer-wiring integration
49
+ work (integration finding F8).
50
+ - `testkit.FakeEventSource.publish`/`publish_async` now serialize the
51
+ native envelope shape (matching `build_envelope`: `event_id`,
52
+ `event_type`, `event_version`, `occurred_at`, `emitted_by`, a member
53
+ `subject`, `data.member_id`, and `context` as an object). They
54
+ previously emitted the same CloudEvents shape the parser wrongly read
55
+ — so the fake producer and the parser were a matched pair that agreed
56
+ with each other and not with the platform, which is why no test caught
57
+ F8. Adapter authors testing consumers against `FakeEventSource` now
58
+ exercise the real on-wire shape. Behavior change to the testkit fake;
59
+ adapter consumer tests that inspected the published bytes for `id`/
60
+ `type`/etc. must read the native field names.
61
+
62
+ ### Added
63
+
64
+ - Raw-bytes parser coverage in `tests/unit/test_event_consumer_conformance.py`
65
+ (`TestParseEnvelope`): asserts `parse_envelope` reads the native
66
+ envelope shape and rejects the legacy CloudEvents shape (F8 regression
67
+ guard), plus a case confirming `member_id` is taken from
68
+ `data.member_id` rather than `subject.id`. The prior parser tests
69
+ asserted the CloudEvents shape — they validated the bug rather than
70
+ catching it. Test payloads are derived from the producer
71
+ (`build_envelope`) pending a captured real event.
72
+
11
73
  ## [0.2.2] - 2026-05-24
12
74
 
13
75
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lightweb-adapter-sdk
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Published Python SDK for the Lightweb adapter contract
5
5
  Project-URL: Homepage, https://github.com/lightweb/adapter-sdk
6
6
  Project-URL: Repository, https://github.com/lightweb/adapter-sdk
@@ -197,7 +197,90 @@ assert stored.external_id == "alice"
197
197
  subsequent calls with the same key update the existing record.
198
198
  ``get_app_account`` returns ``None`` for unknown keys.
199
199
 
200
- ## Beyond the testkit
200
+ ## The fake token endpoint
201
+
202
+ The ``FakeTokenEndpoint`` class is an in-process fake of an OAuth2
203
+ token endpoint. It satisfies the ``HttpClient`` Protocol structurally
204
+ so it can be injected into ``ClientCredentialsTokenProvider``. Adapter
205
+ authors use it to test token acquisition, caching, refresh-before-expiry,
206
+ and clean-failure behaviour.
207
+
208
+ ### Basic usage
209
+
210
+ ```python
211
+ from lightweb_adapter_sdk.testkit import FakeTokenEndpoint
212
+ from lightweb_adapter_sdk.auth import ClientCredentialsTokenProvider
213
+
214
+ fake = FakeTokenEndpoint(access_token="my-token", expires_in=300)
215
+
216
+ provider = ClientCredentialsTokenProvider(
217
+ token_endpoint="https://keycloak.example.com/token",
218
+ client_id="my-adapter",
219
+ client_secret="secret",
220
+ http_client=fake,
221
+ )
222
+
223
+ token = await provider.get_token()
224
+ assert token == "my-token"
225
+ assert fake.call_count == 1
226
+ ```
227
+
228
+ ### Asserting caching
229
+
230
+ Call ``get_token()`` multiple times — the fake's ``call_count`` stays
231
+ at 1 as long as the token is within its lifetime:
232
+
233
+ ```python
234
+ for _ in range(10):
235
+ await provider.get_token()
236
+
237
+ assert fake.call_count == 1 # only one acquisition
238
+ ```
239
+
240
+ ### Refresh-before-expiry
241
+
242
+ Stage a token with a short ``expires_in`` (or use a large
243
+ ``refresh_margin_seconds``) to exercise the refresh path:
244
+
245
+ ```python
246
+ fake = FakeTokenEndpoint(access_token="old-token", expires_in=0)
247
+ provider = ClientCredentialsTokenProvider(
248
+ token_endpoint="...",
249
+ client_id="...",
250
+ client_secret="...",
251
+ http_client=fake,
252
+ refresh_margin_seconds=1000.0,
253
+ )
254
+
255
+ await provider.get_token() # acquires "old-token"
256
+ fake.stage_token("new-token", expires_in=300)
257
+ token = await provider.get_token() # triggers refresh
258
+
259
+ assert token == "new-token"
260
+ assert fake.call_count == 2
261
+ ```
262
+
263
+ ### Error responses
264
+
265
+ Stage an error response to verify clean failure:
266
+
267
+ ```python
268
+ fake = FakeTokenEndpoint()
269
+ fake.stage_error(status_code=401)
270
+ provider = ClientCredentialsTokenProvider(
271
+ token_endpoint="...",
272
+ client_id="...",
273
+ client_secret="...",
274
+ http_client=fake,
275
+ )
276
+
277
+ from lightweb_adapter_sdk.auth import TokenAcquisitionError
278
+
279
+ with pytest.raises(TokenAcquisitionError):
280
+ await provider.get_token()
281
+ ```
282
+
283
+ ### Beyond the testkit
201
284
 
202
285
  The testkit's structural tests verify that an adapter looks like an
203
286
  adapter. They do not verify that the adapter implements the contract
@@ -0,0 +1,42 @@
1
+ """Auth module — OAuth2 token acquisition for adapters.
2
+
3
+ This module provides a reusable client-credentials token provider.
4
+ Adapters authenticate to the entitlements service with a Keycloak-issued
5
+ bearer token; acquiring and refreshing that token is shared platform
6
+ plumbing.
7
+
8
+ Public surface:
9
+
10
+ - ``TokenProvider`` — structural Protocol (single ``get_token()`` method).
11
+ - ``ClientCredentialsTokenProvider`` — concrete provider that runs the
12
+ client_credentials grant, caches the token, and refreshes before expiry.
13
+ - ``TokenAcquisitionError`` — raised when token acquisition fails.
14
+
15
+ Import as::
16
+
17
+ from lightweb_adapter_sdk.auth import (
18
+ ClientCredentialsTokenProvider,
19
+ TokenAcquisitionError,
20
+ TokenProvider,
21
+ )
22
+
23
+ The SDK takes **no new runtime dependency** in this module — the token
24
+ provider receives its HTTP client via injection (see the
25
+ ``HttpClient`` Protocol in ``auth.protocol``).
26
+ """
27
+
28
+ from lightweb_adapter_sdk.auth.errors import TokenAcquisitionError
29
+ from lightweb_adapter_sdk.auth.protocol import (
30
+ HttpClient,
31
+ HttpResponse,
32
+ TokenProvider,
33
+ )
34
+ from lightweb_adapter_sdk.auth.provider import ClientCredentialsTokenProvider
35
+
36
+ __all__ = [
37
+ "ClientCredentialsTokenProvider",
38
+ "HttpClient",
39
+ "HttpResponse",
40
+ "TokenAcquisitionError",
41
+ "TokenProvider",
42
+ ]
@@ -0,0 +1,16 @@
1
+ """Authentication error types for the auth module.
2
+
3
+ See ``docs/contract.md`` for the overall adapter contract.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+
9
+ class TokenAcquisitionError(Exception):
10
+ """Raised when the token provider cannot acquire a bearer token.
11
+
12
+ Covers non-2xx HTTP responses, missing ``access_token`` in the
13
+ response body, and transport errors surfaced by the injected HTTP
14
+ client. Adapter code should catch this rather than letting a raw
15
+ HTTP exception propagate.
16
+ """
@@ -0,0 +1,145 @@
1
+ """Protocols for the auth module.
2
+
3
+ This module defines two structural Protocols:
4
+
5
+ - ``HttpClient`` — a narrow interface describing the async HTTP client
6
+ the token provider calls (a single ``post`` method).
7
+ - ``TokenProvider`` — the public surface of a token provider, a single
8
+ ``get_token`` method.
9
+
10
+ Both are ``@runtime_checkable`` structural Protocols, not base classes.
11
+ See ``src/lightweb_adapter_sdk/event_consumer/protocol.py`` for the
12
+ house style.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import time
19
+ from typing import Any, Awaitable, Callable, Protocol, runtime_checkable
20
+
21
+
22
+ @runtime_checkable
23
+ class HttpClient(Protocol):
24
+ """Narrow structural Protocol for the injected async HTTP client.
25
+
26
+ This is a structural Protocol, not a base class. Any async HTTP
27
+ client whose ``post`` method matches the signature below satisfies
28
+ the contract — the SDK does not import ``httpx`` or any other
29
+ HTTP library.
30
+
31
+ The provider calls exactly one method on the injected client:
32
+ ``post(url, data={...})`` with form-encoded body, expecting a
33
+ response with ``.status_code`` and ``.json()``.
34
+ """
35
+
36
+ async def post(
37
+ self,
38
+ url: str,
39
+ *,
40
+ data: dict[str, str],
41
+ ) -> HttpResponse:
42
+ """Send a form-encoded POST and return the response.
43
+
44
+ The caller passes ``data={"grant_type": "client_credentials", ...}``
45
+ and reads ``response.json()["access_token"]`` on success.
46
+ """
47
+
48
+
49
+ @runtime_checkable
50
+ class HttpResponse(Protocol):
51
+ """Response shape returned by ``HttpClient.post``.
52
+
53
+ Structural Protocol — any object with these attributes satisfies
54
+ the contract regardless of its concrete type.
55
+ """
56
+
57
+ @property
58
+ def status_code(self) -> int:
59
+ """HTTP status code (e.g. 200, 401, 500)."""
60
+
61
+ def json(self) -> dict[str, Any]:
62
+ """Parse and return the response body as a dict.
63
+
64
+ Returns a mixed-type dict — the Keycloak token endpoint
65
+ returns ``access_token`` (str), ``expires_in`` (int), etc.
66
+ """
67
+
68
+ def raise_for_status(self) -> None:
69
+ """Raise an exception for non-2xx status codes."""
70
+
71
+
72
+ @runtime_checkable
73
+ class TokenProvider(Protocol):
74
+ """Protocol defining the token-provider contract.
75
+
76
+ This is a structural Protocol, not a base class. Adapter authors
77
+ can type against this interface without depending on the concrete
78
+ implementation.
79
+
80
+ The single method ``get_token()`` returns a bearer token string.
81
+ Implementations are responsible for caching and refreshing as
82
+ needed.
83
+ """
84
+
85
+ async def get_token(self) -> str:
86
+ """Return a valid bearer token string.
87
+
88
+ Implementations cache the token and refresh it before expiry.
89
+ On acquisition failure, a ``TokenAcquisitionError`` is raised
90
+ rather than returning an empty or invalid token.
91
+ """
92
+
93
+
94
+ class _RefreshGuard:
95
+ """Internal helper: ensures only one refresh runs at a time.
96
+
97
+ Wraps an async acquisition callable with an ``asyncio.Lock`` so
98
+ that concurrent callers near expiry trigger a single re-acquire
99
+ rather than a stampede.
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ acquire: Callable[[], Awaitable[tuple[str, float]]],
105
+ refresh_margin: float,
106
+ ) -> None:
107
+ self._acquire = acquire
108
+ self._margin = refresh_margin
109
+ self._lock = asyncio.Lock()
110
+ self._token: str | None = None
111
+ self._expires_at: float = 0.0
112
+
113
+ @property
114
+ def token(self) -> str | None:
115
+ return self._token
116
+
117
+ @property
118
+ def expires_at(self) -> float:
119
+ return self._expires_at
120
+
121
+ def is_expired(self) -> bool:
122
+ """Return True if the cached token is gone or past the refresh margin."""
123
+ if self._token is None:
124
+ return True
125
+ return time.monotonic() >= self._expires_at - self._margin
126
+
127
+ async def get_or_refresh(self) -> str:
128
+ """Return cached token or acquire a new one (with lock guard)."""
129
+ if not self.is_expired():
130
+ assert self._token is not None
131
+ return self._token
132
+
133
+ async with self._lock:
134
+ # Double-check after acquiring the lock — another caller may
135
+ # have refreshed while we waited. Use actual expiry (not
136
+ # margin-adjusted) because the margin is for the fast-path
137
+ # outside the lock; inside the lock we just need to know if
138
+ # the token is still actually valid.
139
+ if self._token is not None and self._expires_at > time.monotonic():
140
+ return self._token
141
+
142
+ token, expires_in = await self._acquire()
143
+ self._token = token
144
+ self._expires_at = time.monotonic() + expires_in
145
+ return token
@@ -0,0 +1,128 @@
1
+ """Client-credentials token provider.
2
+
3
+ Acquires and caches OAuth2 bearer tokens via the client_credentials
4
+ grant. Tokens are refreshed before expiry to avoid handing out
5
+ near-expired tokens to downstream requests.
6
+
7
+ See ``docs/contract.md`` for the overall adapter contract.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+
14
+ from lightweb_adapter_sdk.auth.errors import TokenAcquisitionError
15
+ from lightweb_adapter_sdk.auth.protocol import (
16
+ HttpClient,
17
+ TokenProvider,
18
+ _RefreshGuard,
19
+ )
20
+
21
+ # Default expires_in fallback when the token endpoint omits the field.
22
+ # Keycloak defaults to 300s (5 min); this mirrors that convention.
23
+ _DEFAULT_EXPIRES_IN_SECONDS: float = 300.0
24
+
25
+
26
+ class ClientCredentialsTokenProvider(TokenProvider):
27
+ """OAuth2 client-credentials token provider.
28
+
29
+ Constructs bearer tokens by POSTing form-encoded credentials to an
30
+ OAuth2 token endpoint. The acquired token is cached and returned
31
+ on subsequent ``get_token()`` calls until it is within
32
+ ``refresh_margin_seconds`` of expiry, at which point a new token
33
+ is acquired.
34
+
35
+ The provider takes an **injected** async HTTP client rather than
36
+ importing an HTTP library directly, keeping the SDK's runtime
37
+ dependency surface at ``pydantic`` + ``nats-py``. Any async HTTP
38
+ client whose ``post`` method matches the ``HttpClient`` Protocol
39
+ (a single ``post(url, data={...})`` returning an object with
40
+ ``.status_code``, ``.json()``) is accepted.
41
+
42
+ Concurrency: ``get_token()`` is safe for concurrent callers. When
43
+ the cached token is expired, a single re-acquisition is performed
44
+ under an ``asyncio.Lock``; all concurrent callers wait for and
45
+ receive the same refreshed token.
46
+
47
+ Args:
48
+ token_endpoint: The OAuth2 token endpoint URL.
49
+ client_id: The client identifier.
50
+ client_secret: The client secret.
51
+ http_client: An injected async HTTP client satisfying the
52
+ ``HttpClient`` Protocol.
53
+ refresh_margin_seconds: Seconds before expiry to trigger a
54
+ refresh. The default is ``30.0`` — this covers the common
55
+ case where a token has a ~5-minute lifetime and a downstream
56
+ request may take several seconds; handing back a token with
57
+ < 30 s remaining would cause latent failures. Tune this
58
+ value if your token lifetime or request latency profile
59
+ differs.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ token_endpoint: str,
65
+ client_id: str,
66
+ client_secret: str,
67
+ http_client: HttpClient,
68
+ refresh_margin_seconds: float = 30.0,
69
+ ) -> None:
70
+ self._endpoint = token_endpoint
71
+ self._client_id = client_id
72
+ self._client_secret = client_secret
73
+ self._http_client = http_client
74
+ self._margin = refresh_margin_seconds
75
+ self._guard: _RefreshGuard | None = None
76
+ self._init_lock = asyncio.Lock()
77
+
78
+ async def _acquire(self) -> tuple[str, float]:
79
+ """POST the client_credentials grant and return (token, expires_in)."""
80
+ try:
81
+ resp = await self._http_client.post(
82
+ self._endpoint,
83
+ data={
84
+ "grant_type": "client_credentials",
85
+ "client_id": self._client_id,
86
+ "client_secret": self._client_secret,
87
+ },
88
+ )
89
+ except Exception as exc:
90
+ raise TokenAcquisitionError(
91
+ f"HTTP request to token endpoint failed: {exc}"
92
+ ) from exc
93
+
94
+ if resp.status_code < 200 or resp.status_code >= 300:
95
+ raise TokenAcquisitionError(
96
+ f"Token endpoint returned status {resp.status_code}"
97
+ )
98
+
99
+ body = resp.json()
100
+ token = body.get("access_token")
101
+ if not token:
102
+ raise TokenAcquisitionError("Token response missing 'access_token' field")
103
+ expires_in: float = float(body.get("expires_in", _DEFAULT_EXPIRES_IN_SECONDS))
104
+ return (token, expires_in)
105
+
106
+ async def get_token(self) -> str:
107
+ """Return a valid bearer token, acquiring or refreshing as needed.
108
+
109
+ On first call, acquires a token via the client_credentials grant.
110
+ On subsequent calls, returns the cached token unless it is within
111
+ ``refresh_margin_seconds`` of expiry — then re-acquires.
112
+
113
+ Raises:
114
+ TokenAcquisitionError: If the token endpoint returns a
115
+ non-2xx response, the response body lacks
116
+ ``access_token``, or a transport error occurs.
117
+ """
118
+ if self._guard is None:
119
+ async with self._init_lock:
120
+ # Double-check after acquiring the init lock — another
121
+ # concurrent caller may have created the guard while we
122
+ # waited.
123
+ if self._guard is None:
124
+ self._guard = _RefreshGuard(
125
+ self._acquire,
126
+ self._margin,
127
+ )
128
+ return await self._guard.get_or_refresh()
@@ -0,0 +1,119 @@
1
+ """Envelope parser: raw JetStream payload → EventEnvelope.
2
+
3
+ The platform publishes JetStream messages as JSON in the entitlements
4
+ event-envelope shape (per ``libs/events/schemas/entitlements/_envelope.json``,
5
+ Conventions §4.3, emitted by the entitlements service's
6
+ ``outbox/envelope.py::build_envelope``):
7
+
8
+ .. code-block:: json
9
+
10
+ {
11
+ "event_id": "evt_<base58>",
12
+ "event_type": "entitlements.<entity>.<verb>",
13
+ "event_version": "1.0",
14
+ "occurred_at": "2026-05-17T12:00:00Z",
15
+ "emitted_by": "service:entitlements",
16
+ "subject": {"type": "member", "id": "mem_..."},
17
+ "data": {"member_id": "mem_...", ...},
18
+ "context": {...}
19
+ }
20
+
21
+ This module parses that shape into the SDK's ``EventEnvelope``,
22
+ flattening ``data.member_id`` to the top level and omitting ``subject``
23
+ and ``data`` (beyond ``member_id``). ``subject`` is tolerated and
24
+ ignored — the platform always emits it (the schema requires it), but
25
+ the consumer's hook is ``data.member_id``, not ``subject.id`` (subject's
26
+ ``id`` varies by event type: member_id for member events, subscription_id
27
+ for subscription events, etc.).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ from datetime import datetime, timezone
34
+
35
+ from lightweb_adapter_sdk.types import EventEnvelope
36
+
37
+
38
+ class ParseError(ValueError):
39
+ """The raw payload could not be parsed into an EventEnvelope."""
40
+
41
+
42
+ def parse_envelope(raw_payload: bytes) -> EventEnvelope:
43
+ """Parse a raw JetStream message payload into an ``EventEnvelope``.
44
+
45
+ Args:
46
+ raw_payload: The raw bytes from JetStream, expected to be
47
+ UTF-8 encoded JSON matching the entitlements event-envelope
48
+ schema (``_envelope.json``).
49
+
50
+ Returns:
51
+ A fully-populated ``EventEnvelope``.
52
+
53
+ Raises:
54
+ ParseError: If the payload is not valid JSON, is missing
55
+ required fields, or has an unexpected shape.
56
+ """
57
+ try:
58
+ data = json.loads(raw_payload)
59
+ except (json.JSONDecodeError, TypeError) as exc:
60
+ raise ParseError(f"Payload is not valid JSON: {exc}") from exc
61
+
62
+ if not isinstance(data, dict):
63
+ raise ParseError(
64
+ f"Payload must be a JSON object, got {type(data).__name__}"
65
+ )
66
+
67
+ # Required top-level fields, per _envelope.json. ``subject`` and
68
+ # ``data`` are also schema-required but handled separately below
69
+ # (subject is tolerated-and-ignored; data is read for member_id).
70
+ for field in (
71
+ "event_id",
72
+ "event_type",
73
+ "event_version",
74
+ "occurred_at",
75
+ "emitted_by",
76
+ ):
77
+ if field not in data:
78
+ raise ParseError(f"Missing required field: {field}")
79
+
80
+ # data.member_id is the source for EventEnvelope.member_id.
81
+ if "data" not in data or not isinstance(data["data"], dict):
82
+ raise ParseError("Missing or invalid 'data' field")
83
+ if "member_id" not in data["data"]:
84
+ raise ParseError("Missing 'data.member_id' field")
85
+
86
+ member_id = data["data"]["member_id"]
87
+ if not isinstance(member_id, str):
88
+ raise ParseError(
89
+ f"'data.member_id' must be a string, got {type(member_id).__name__}"
90
+ )
91
+
92
+ # context is optional and preserved when present. The producer emits
93
+ # an empty object ``{}`` (not null) when there is no context; both an
94
+ # empty dict and an absent/None context are accepted here.
95
+ context = data.get("context")
96
+ if context is not None and not isinstance(context, dict):
97
+ raise ParseError("'context' must be an object when present")
98
+
99
+ # occurred_at — accept ISO-8601 strings (the producer emits with a
100
+ # trailing 'Z'; datetime.fromisoformat handles 'Z' on Python 3.11+).
101
+ occurred_at_str = data["occurred_at"]
102
+ try:
103
+ occurred_at = datetime.fromisoformat(occurred_at_str)
104
+ if occurred_at.tzinfo is None:
105
+ occurred_at = occurred_at.replace(tzinfo=timezone.utc)
106
+ except (ValueError, TypeError) as exc:
107
+ raise ParseError(
108
+ f"Invalid 'occurred_at' field: {occurred_at_str!r}: {exc}"
109
+ ) from exc
110
+
111
+ return EventEnvelope(
112
+ event_id=data["event_id"],
113
+ event_type=data["event_type"],
114
+ event_version=data["event_version"],
115
+ occurred_at=occurred_at,
116
+ emitted_by=data["emitted_by"],
117
+ member_id=member_id,
118
+ context=context,
119
+ )
@@ -8,6 +8,11 @@ Import from this module to test your adapter during development:
8
8
  - ``FakeEntitlementsService``: an in-process fake of the platform's
9
9
  entitlements service for staging state in tests that drive
10
10
  ``apply()`` or ``reconcile()``.
11
+ - ``FakeEventSource``: an in-process fake of the JetStream event source
12
+ for testing event consumers.
13
+ - ``FakeTokenEndpoint``: an in-process fake of an OAuth2 token endpoint
14
+ for testing the auth provider's token acquisition, caching, and
15
+ refresh-before-expiry behaviour.
11
16
 
12
17
  Adapter authors re-export the test functions in their own
13
18
  ``tests/test_structural.py`` and provide an ``adapter_under_test``
@@ -18,6 +23,7 @@ See docs/testkit.md for full usage instructions.
18
23
 
19
24
  from lightweb_adapter_sdk.testkit.fake_entitlements import FakeEntitlementsService
20
25
  from lightweb_adapter_sdk.testkit.fake_event_source import FakeEventSource
26
+ from lightweb_adapter_sdk.testkit.fake_token_endpoint import FakeTokenEndpoint
21
27
  from lightweb_adapter_sdk.testkit.structural import (
22
28
  test_capabilities_returns_well_shaped,
23
29
  test_health_is_callable,
@@ -28,6 +34,7 @@ from lightweb_adapter_sdk.testkit.structural import (
28
34
  __all__ = [
29
35
  "FakeEntitlementsService",
30
36
  "FakeEventSource",
37
+ "FakeTokenEndpoint",
31
38
  "test_satisfies_protocol",
32
39
  "test_capabilities_returns_well_shaped",
33
40
  "test_health_is_callable",
@@ -4,6 +4,17 @@ Provides ``FakeEventSource``, an in-process fake that publishes
4
4
  ``EventEnvelope`` instances as raw JetStream-style JSON bytes,
5
5
  allowing tests to drive event consumer behavior without a real
6
6
  NATS/JetStream connection.
7
+
8
+ The published bytes match the platform's native event-envelope shape
9
+ (per ``libs/events/schemas/entitlements/_envelope.json`` and the
10
+ entitlements service's ``build_envelope``): ``event_id``, ``event_type``,
11
+ ``event_version``, ``occurred_at``, ``emitted_by``, ``subject``,
12
+ ``data`` (carrying ``member_id``), and ``context``. This is the shape
13
+ ``parse_envelope`` consumes, so a round-trip publish→handle_event
14
+ exercises the real parse path. (Prior to 0.2.3 this fake emitted a
15
+ CloudEvents shape — ``id``/``type``/``specversion``/``time``/``source``
16
+ — matching a parser that read the same wrong shape; neither matched the
17
+ platform. See CHANGELOG 0.2.3 / integration finding F8.)
7
18
  """
8
19
 
9
20
  from __future__ import annotations
@@ -23,6 +34,10 @@ class FakeEventSource:
23
34
  callbacks (consumers). Events are stored internally for inspection
24
35
  and can be published synchronously or asynchronously.
25
36
 
37
+ The serialized bytes use the platform's native envelope shape so
38
+ that consumers exercising ``parse_envelope`` see exactly what the
39
+ entitlements service emits.
40
+
26
41
  Args:
27
42
  subject: The subject name this fake source publishes to
28
43
  (e.g. ``"entitlements.member.>"``).
@@ -47,6 +62,28 @@ class FakeEventSource:
47
62
  self._sequence += 1
48
63
  return f"evt-fake-{self._sequence:04d}"
49
64
 
65
+ def _serialize(self, envelope: EventEnvelope) -> bytes:
66
+ """Serialize an ``EventEnvelope`` to the platform's native shape.
67
+
68
+ Mirrors the entitlements service's ``build_envelope`` output:
69
+ native field names, a member ``subject`` derived from
70
+ ``member_id`` (the consumer ignores ``subject`` but the real
71
+ envelope always carries it), ``member_id`` nested under
72
+ ``data``, and ``context`` as an object (``{}`` when empty,
73
+ matching the producer, which never emits null).
74
+ """
75
+ payload_dict: dict[str, Any] = {
76
+ "event_id": envelope.event_id,
77
+ "event_type": envelope.event_type,
78
+ "event_version": envelope.event_version,
79
+ "occurred_at": envelope.occurred_at.isoformat(),
80
+ "emitted_by": envelope.emitted_by,
81
+ "subject": {"type": "member", "id": envelope.member_id},
82
+ "data": {"member_id": envelope.member_id},
83
+ "context": envelope.context if envelope.context is not None else {},
84
+ }
85
+ return json.dumps(payload_dict).encode("utf-8")
86
+
50
87
  def publish(self, envelope: EventEnvelope) -> bytes:
51
88
  """Publish an event envelope as raw JSON bytes.
52
89
 
@@ -58,18 +95,7 @@ class FakeEventSource:
58
95
  Returns:
59
96
  The raw JSON bytes that were published.
60
97
  """
61
- payload_dict = {
62
- "id": envelope.event_id,
63
- "type": envelope.event_type,
64
- "specversion": envelope.event_version,
65
- "time": envelope.occurred_at.isoformat(),
66
- "source": envelope.emitted_by,
67
- "data": {"member_id": envelope.member_id},
68
- }
69
- if envelope.context is not None:
70
- payload_dict["context"] = envelope.context
71
-
72
- raw = json.dumps(payload_dict).encode("utf-8")
98
+ raw = self._serialize(envelope)
73
99
  self._published.append((envelope, raw))
74
100
 
75
101
  for consumer in self._consumers:
@@ -88,18 +114,7 @@ class FakeEventSource:
88
114
  Returns:
89
115
  The raw JSON bytes that were published.
90
116
  """
91
- payload_dict = {
92
- "id": envelope.event_id,
93
- "type": envelope.event_type,
94
- "specversion": envelope.event_version,
95
- "time": envelope.occurred_at.isoformat(),
96
- "source": envelope.emitted_by,
97
- "data": {"member_id": envelope.member_id},
98
- }
99
- if envelope.context is not None:
100
- payload_dict["context"] = envelope.context
101
-
102
- raw = json.dumps(payload_dict).encode("utf-8")
117
+ raw = self._serialize(envelope)
103
118
  self._published.append((envelope, raw))
104
119
 
105
120
  for consumer in self._consumers:
@@ -0,0 +1,145 @@
1
+ """Fake token endpoint for testing the auth provider.
2
+
3
+ Provides ``FakeTokenEndpoint``, an in-process fake that satisfies the
4
+ ``HttpClient`` Protocol structurally. Tests use it to stage token
5
+ responses, assert caching behaviour, and exercise refresh-before-expiry
6
+ and clean-failure paths.
7
+
8
+ See docs/testkit.md for usage instructions.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+ import asyncio
15
+
16
+ class _FakeResponse:
17
+ """In-memory response returned by ``FakeTokenEndpoint.post``.
18
+
19
+ Structural match for ``httpx.Response`` as consumed by the provider:
20
+ ``.status_code``, ``.json()``, ``.raise_for_status()``.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ status_code: int,
26
+ body: dict[str, Any],
27
+ ) -> None:
28
+ self._status_code = status_code
29
+ self._body = body
30
+
31
+ @property
32
+ def status_code(self) -> int:
33
+ return self._status_code
34
+
35
+ def json(self) -> dict[str, Any]:
36
+ return dict(self._body)
37
+
38
+ def raise_for_status(self) -> None:
39
+ if self._status_code < 200 or self._status_code >= 300:
40
+ raise RuntimeError(f"HTTP {self._status_code}: {self._body}")
41
+
42
+
43
+ class FakeTokenEndpoint:
44
+ """In-process fake of an OAuth2 token endpoint.
45
+
46
+ Satisfies the ``HttpClient`` Protocol structurally so it can be
47
+ injected into ``ClientCredentialsTokenProvider``. Each call to
48
+ ``post`` returns a canned token response (or error) determined by
49
+ the staged state.
50
+
51
+ Tests use this to:
52
+ - Stage the token value(s) it returns and the ``expires_in``.
53
+ - Count how many times it was called (to assert caching).
54
+ - Return a near-expiry / expired token so a test can assert
55
+ refresh-before-expiry triggers re-acquisition.
56
+ - Return an error response (non-2xx / missing ``access_token``)
57
+ so a test can assert clean failure.
58
+
59
+ Args:
60
+ access_token: The token value returned by ``post``.
61
+ expires_in: Seconds until expiry (default 300).
62
+ status_code: HTTP status code to return (default 200).
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ access_token: str = "fake-token-00000000000000000000000000000000",
68
+ expires_in: int = 300,
69
+ status_code: int = 200,
70
+ ) -> None:
71
+ self._access_token = access_token
72
+ self._expires_in = expires_in
73
+ self._status_code = status_code
74
+ self._call_count = 0
75
+
76
+ async def post(
77
+ self,
78
+ url: str,
79
+ *,
80
+ data: dict[str, str],
81
+ ) -> _FakeResponse:
82
+ """Return the staged token response and increment the call count.
83
+
84
+ Args:
85
+ url: The token endpoint URL (ignored; the fake accepts any URL).
86
+ data: The form-encoded data sent by the provider
87
+ (grant_type, client_id, client_secret).
88
+
89
+ Returns:
90
+ A ``_FakeResponse`` with the staged status code and body.
91
+ """
92
+ await asyncio.sleep(0)
93
+ self._call_count += 1
94
+ body: dict[str, Any] = {
95
+ "access_token": self._access_token,
96
+ "token_type": "Bearer",
97
+ "expires_in": self._expires_in,
98
+ }
99
+ return _FakeResponse(self._status_code, body)
100
+
101
+ @property
102
+ def call_count(self) -> int:
103
+ """Number of times ``post`` was called."""
104
+ return self._call_count
105
+
106
+ def stage_token(
107
+ self,
108
+ access_token: str,
109
+ expires_in: int = 300,
110
+ ) -> None:
111
+ """Stage a new token value and expiry for the next ``post`` call.
112
+
113
+ Args:
114
+ access_token: The token string to return.
115
+ expires_in: Seconds until expiry (default 300).
116
+ """
117
+ self._access_token = access_token
118
+ self._expires_in = expires_in
119
+
120
+ def stage_error(
121
+ self,
122
+ status_code: int = 401,
123
+ error_body: dict[str, Any] | None = None,
124
+ ) -> None:
125
+ """Stage an error response for the next ``post`` call.
126
+
127
+ Args:
128
+ status_code: HTTP status code (e.g. 401, 500).
129
+ error_body: Optional body dict (defaults to
130
+ ``{"error": "invalid_client"}``).
131
+ """
132
+ self._status_code = status_code
133
+ self._access_token = ""
134
+ self._expires_in = 0
135
+ if error_body is not None:
136
+ self._error_body = error_body
137
+ else:
138
+ self._error_body = {"error": "invalid_client"}
139
+
140
+ def reset(self) -> None:
141
+ """Reset call count and restore default token."""
142
+ self._call_count = 0
143
+ self.stage_token("fake-token-00000000000000000000000000000000")
144
+ self._expires_in = 300
145
+ self._status_code = 200
@@ -1,94 +0,0 @@
1
- """Envelope parser: raw JetStream payload → EventEnvelope.
2
-
3
- The platform publishes JetStream messages as JSON with the following
4
- shape (per ``libs/events/schemas/entitlements/_envelope.json``):
5
-
6
- .. code-block:: json
7
-
8
- {
9
- "id": "<event-id>",
10
- "type": "entitlements.<entity>.<verb>",
11
- "specversion": "1.0",
12
- "time": "2026-05-17T12:00:00Z",
13
- "source": "service:entitlements",
14
- "subject": {"type": "member", "id": "mem_..."},
15
- "data": {"member_id": "mem_...", ...},
16
- "context": {"trace_context": "..."}
17
- }
18
-
19
- This module parses that shape into the SDK's ``EventEnvelope``,
20
- flattening ``data.member_id`` to the top level and omitting ``subject``
21
- and ``data`` (beyond ``member_id``).
22
- """
23
-
24
- from __future__ import annotations
25
-
26
- import json
27
- from datetime import datetime, timezone
28
-
29
- from lightweb_adapter_sdk.types import EventEnvelope
30
-
31
-
32
- class ParseError(ValueError):
33
- """The raw payload could not be parsed into an EventEnvelope."""
34
-
35
-
36
- def parse_envelope(raw_payload: bytes) -> EventEnvelope:
37
- """Parse a raw JetStream message payload into an ``EventEnvelope``.
38
-
39
- Args:
40
- raw_payload: The raw bytes from JetStream, expected to be
41
- UTF-8 encoded JSON matching the platform envelope schema.
42
-
43
- Returns:
44
- A fully-populated ``EventEnvelope``.
45
-
46
- Raises:
47
- ParseError: If the payload is not valid JSON, is missing
48
- required fields, or has an unexpected shape.
49
- """
50
- try:
51
- data = json.loads(raw_payload)
52
- except (json.JSONDecodeError, TypeError) as exc:
53
- raise ParseError(f"Payload is not valid JSON: {exc}") from exc
54
-
55
- # Required top-level fields
56
- for field in ("id", "type", "specversion", "time", "source"):
57
- if field not in data:
58
- raise ParseError(f"Missing required field: {field}")
59
-
60
- # data.member_id is the source for EventEnvelope.member_id
61
- if "data" not in data or not isinstance(data["data"], dict):
62
- raise ParseError("Missing or invalid 'data' field")
63
- if "member_id" not in data["data"]:
64
- raise ParseError("Missing 'data.member_id' field")
65
-
66
- member_id = data["data"]["member_id"]
67
- if not isinstance(member_id, str):
68
- raise ParseError(
69
- f"'data.member_id' must be a string, got {type(member_id).__name__}"
70
- )
71
-
72
- # context is optional but preserved when present
73
- context = data.get("context")
74
- if context is not None and not isinstance(context, dict):
75
- raise ParseError("'context' must be an object when present")
76
-
77
- # Parse time — accept ISO-8601 strings
78
- time_str = data["time"]
79
- try:
80
- occurred_at = datetime.fromisoformat(time_str)
81
- if occurred_at.tzinfo is None:
82
- occurred_at = occurred_at.replace(tzinfo=timezone.utc)
83
- except (ValueError, TypeError) as exc:
84
- raise ParseError(f"Invalid 'time' field: {time_str}: {exc}") from exc
85
-
86
- return EventEnvelope(
87
- event_id=data["id"],
88
- event_type=data["type"],
89
- event_version=data["specversion"],
90
- occurred_at=occurred_at,
91
- emitted_by=data["source"],
92
- member_id=member_id,
93
- context=context,
94
- )