regent-httpsig 0.2.0__tar.gz → 0.4.0__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 (32) hide show
  1. regent_httpsig-0.4.0/CHANGELOG.md +98 -0
  2. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/PKG-INFO +67 -1
  3. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/README.md +66 -0
  4. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/pyproject.toml +1 -1
  5. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/__init__.py +34 -2
  6. regent_httpsig-0.4.0/src/regent_httpsig/budget.py +345 -0
  7. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/config.py +7 -0
  8. regent_httpsig-0.4.0/src/regent_httpsig/fastapi.py +358 -0
  9. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/sfv.py +56 -0
  10. regent_httpsig-0.4.0/src/regent_httpsig/usage.py +223 -0
  11. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/verify.py +42 -17
  12. regent_httpsig-0.4.0/tests/test_budget.py +489 -0
  13. regent_httpsig-0.4.0/tests/test_usage.py +231 -0
  14. regent_httpsig-0.4.0/vectors/aauth-budgets-vectors.json +124 -0
  15. regent_httpsig-0.4.0/vectors/generate.py +157 -0
  16. regent_httpsig-0.2.0/CHANGELOG.md +0 -46
  17. regent_httpsig-0.2.0/src/regent_httpsig/fastapi.py +0 -94
  18. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/.github/workflows/ci.yml +0 -0
  19. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/.github/workflows/release.yml +0 -0
  20. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/.gitignore +0 -0
  21. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/LICENSE +0 -0
  22. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/SECURITY.md +0 -0
  23. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/examples/fastapi_verify.py +0 -0
  24. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/examples/httpx_signer.py +0 -0
  25. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/cli.py +0 -0
  26. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/jwk.py +0 -0
  27. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/netguard.py +0 -0
  28. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/src/regent_httpsig/sign.py +0 -0
  29. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/tests/test_netguard.py +0 -0
  30. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/tests/test_signer.py +0 -0
  31. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/tests/test_vectors.py +0 -0
  32. {regent_httpsig-0.2.0 → regent_httpsig-0.4.0}/tests/test_verifier.py +0 -0
@@ -0,0 +1,98 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0
4
+
5
+ **AAuth Budgets — the August 20 editor's-copy additions** (allocation model,
6
+ omitted cost, `required`, usage counters):
7
+
8
+ - **`required` member**: an `insufficient-budget` refusal now carries the
9
+ refused request's maximum cost in `AAuth-Budget`, so the agent's retry is a
10
+ calculation (fit the bound to `remaining`) rather than a search.
11
+ - **Streaming / cost-omitted** (§cost-omitted): a streamed response states
12
+ `reserved` (REQUIRED when `cost` is omitted) with `remaining` already net of
13
+ the hold, commits when the stream ends (`request.state.budget_cost` may be
14
+ set mid-stream), and the agent recovers the exact figure from the next
15
+ response's `remaining`. SSE is recognized automatically; other streams opt
16
+ in with `request.state.budget_streaming = True`.
17
+ - **Usage endpoint** (§Usage Counters): `make_usage_endpoint(meter, …)` —
18
+ scope queries (`sub` calendar counters: day/week/month/year/all_time on UTC
19
+ boundaries) and per-key `jkts` queries; unrecognized scope values omit
20
+ `usage` (never zero — a query must not reveal whether an account exists);
21
+ unrecognized/pruned thumbprints are omitted from `jkts` (never zero — a
22
+ false zero misleads an allocation decision); per-key figures pruned on 24h
23
+ IDLE, so a key in continuous use is never pruned; figures keyed by the
24
+ issuing PS, so the endpoint structurally answers only the party whose
25
+ tokens were accepted. PS authentication is pluggable (`authenticate_ps`).
26
+ - **Signed usage responses** (§The Signed Response): `ResponseSigner` — an
27
+ Ed25519 HTTP Sig over `@status`, `content-type`, `content-digest`, bound to
28
+ the request via `@authority`/`@path` with the `req` parameter.
29
+ - **`validate_budget_grant`**: the §Resource Metadata MUSTs (only declared
30
+ units; declared decimals) as a pre-mint guard against the draft's
31
+ "thousandfold error".
32
+
33
+ ## 0.3.0
34
+
35
+ **AAuth Budgets** (draft-hardt-aauth-budgets, editor's copy) — the resource
36
+ side, first known implementation (running in production on get4agent.com):
37
+
38
+ - **Auth tokens** (`typ: aa-auth+jwt`): PS-issued budget carriers verified
39
+ against a configuration-pinned PS (`HttpsigConfig.trusted_ps`: issuer →
40
+ JWKS URL), `aud`-checked against `resource_url`, `cnf`-bound, ≤1h lifetime.
41
+ - **`BudgetClaim` + `InMemoryMeter`**: atomic reserve → commit → release
42
+ pooled per the draft's `(iss, sub, aud)` aggregation key; conservative
43
+ crash-safety (an unresolved reservation counts as consumed); consumption
44
+ records **scoped to the presenting agent's `jkt`** so one agent never
45
+ learns about a sibling's spending.
46
+ - **`BudgetMiddleware`** (FastAPI): `price_fn` hook, metering cycle,
47
+ refusals (401 + `AAuth-Requirement` with `reason=insufficient-budget` /
48
+ `budget-exhausted` and an optional resource token via
49
+ `resource_token_provider`), per-response `AAuth-Budget` header; error
50
+ responses release the reservation.
51
+ - `build_aauth_budget_header` / `build_aauth_requirement` — RFC 9651
52
+ serialization (note: the field is a Dictionary, so members are
53
+ comma-separated; the draft's §11 example shows parameter separators).
54
+
55
+ ## 0.2.0
56
+
57
+ AAuth draft **-11** support (per the editor's copy, ahead of datatracker publication):
58
+
59
+ - **Fully-specified algorithms (RFC 9864):** `Ed25519` accepted everywhere
60
+ (registered with PyJWT, including JWKS entries PyJWK cannot parse).
61
+ New `HttpsigConfig.require_fully_specified_algs` enforces the -11 MUST NOT on
62
+ the polymorphic `EdDSA`; the default keeps accepting it while the -10
63
+ ecosystem migrates, and will flip when -11 posts.
64
+ - **Person tokens** (`typ: aa-person+jwt`): PS-issued, per-resource `aud`,
65
+ `cnf`-bound, ≤1h lifetime — verified via `{iss}/.well-known/aauth-person.json`.
66
+ Opt-in: set `HttpsigConfig.resource_url` (the token's `aud` must name it).
67
+ Result scheme: `"aauth-person"`, `sub` = the PS's directed user identifier.
68
+ - Strict mode also enforces the -11 requirement that `cnf.jwk` carries a
69
+ fully-specified `alg` member.
70
+
71
+ ## 0.1.1
72
+
73
+ - AAuth: tolerate absent `keyid` (RFC 9421 makes it optional; the key comes from
74
+ the token's `cnf.jwk`). Exposed by cross-library interop with
75
+ christian-posta/aauth-signing, whose signers correctly omit it; that signer's
76
+ exact keyid-less shape is now pinned in CI.
77
+
78
+ ## 0.1.0
79
+
80
+ Initial release, extracted from Regent Protocol's production marketplace
81
+ (get4agent.com), where it authenticates self-onboarding AI agents.
82
+
83
+ - `HttpsigVerifier` — RFC 9421 verification for both agent dialects:
84
+ - Web Bot Auth (draft -05): sf-dictionary `Signature-Agent` with `;key=`
85
+ member selection AND the legacy sf-string form OpenAI ships in production;
86
+ key discovery via `/.well-known/http-message-signatures-directory`.
87
+ - AAuth (identity-based mode, `[aauth]` extra): `aa-agent+jwt` in
88
+ `Signature-Key`, issuer JWKS discovery, `cnf.jwk` proof of possession.
89
+ - SSRF-guarded directory fetching (https-only, public-IP-only, no redirects,
90
+ size-capped) with bounded per-instance caching.
91
+ - `EgressSigner` + `regent-httpsig keygen` — sign outbound agent traffic
92
+ (Web Bot Auth), generate keys and ready-to-publish well-known files.
93
+ - FastAPI integration (`[fastapi]` extra): `SignatureDep` (enrichment) and
94
+ `RequiredSignatureDep` (authentication with a self-explaining 401).
95
+ - Test suite pinned to the official RFC 9421 B.2.6 vector, both Web Bot Auth
96
+ appendix vectors (A.2.2 re-signed — the draft's printed signature does not
97
+ verify over its own base; reported), and full sign→verify roundtrips for
98
+ both dialects.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: regent-httpsig
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth.
5
5
  Project-URL: Homepage, https://github.com/regent-protocol/regent-httpsig
6
6
  Project-URL: Repository, https://github.com/regent-protocol/regent-httpsig
@@ -138,6 +138,72 @@ keyid-less shape is pinned in CI.
138
138
  [christian-posta/aauth-python-library](https://github.com/christian-posta/aauth-python-library) —
139
139
  this library is the thin relying-party verifier that handles both dialects.
140
140
 
141
+ ## Budgets: meter a spending envelope (draft-hardt-aauth-budgets)
142
+
143
+ An agent can carry a PS-issued **auth token** (`typ: aa-auth+jwt`) with a
144
+ `budget` claim — a spending envelope it uses offline, no per-call round trip
145
+ to the control plane. The middleware does the whole resource-side checklist:
146
+ verify the token against your pinned PS, atomically reserve → commit →
147
+ release per request, answer with `AAuth-Budget`, and refuse exhausted
148
+ envelopes with a `401` + `AAuth-Requirement` (optionally carrying your signed
149
+ resource token with the agent's own consumption records — scoped to its key,
150
+ so one agent never learns about a sibling's spending):
151
+
152
+ ```python
153
+ from regent_httpsig import HttpsigConfig, HttpsigVerifier, InMemoryMeter
154
+ from regent_httpsig.fastapi import BudgetMiddleware
155
+
156
+ app.add_middleware(
157
+ BudgetMiddleware,
158
+ verifier=HttpsigVerifier(HttpsigConfig(
159
+ resource_url="https://api.example",
160
+ trusted_ps={"my-ps": "https://ps.example/jwks.json"},
161
+ )),
162
+ meter=InMemoryMeter(),
163
+ price_fn=lambda request: PRICES.get(request.url.path), # max cost, minor units
164
+ )
165
+ ```
166
+
167
+ The only thing the library cannot do for you is pricing (`price_fn`) — that
168
+ is your domain. First known implementation of the draft; running in
169
+ production on [get4agent.com](https://get4agent.com).
170
+
171
+ **The August 20 additions** are covered too:
172
+
173
+ - `insufficient-budget` refusals carry **`required`** — the refused request's
174
+ maximum cost — so the agent lowers its bound and retries instead of guessing.
175
+ - **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
176
+ the header, commit when the stream ends (set `request.state.budget_cost`
177
+ mid-stream if you learn the actual), and the agent recovers the exact cost
178
+ from the next response's `remaining`.
179
+ - The **usage endpoint** (§Usage Counters) lets your PS query consumption
180
+ without the agent in the loop — scope counters (`sub`, UTC calendar buckets)
181
+ and per-key `jkts` totals, optionally signed:
182
+
183
+ ```python
184
+ from regent_httpsig import InMemoryMeter, ResponseSigner, make_usage_endpoint
185
+
186
+ handler = make_usage_endpoint(
187
+ meter,
188
+ authenticate_ps=my_ps_authenticator, # verify the PS's jwks_uri signature
189
+ unit="USD", decimals=6,
190
+ signer=ResponseSigner(seed=SEED, jwks_url="https://api.example/jwks.json"),
191
+ )
192
+
193
+ @app.post("/usage")
194
+ async def usage(request: Request):
195
+ return await handler(request)
196
+ ```
197
+
198
+ - `validate_budget_grant(unit, decimals, budget_units)` enforces the resource
199
+ metadata MUSTs before you mint a resource token — the "thousandfold error"
200
+ guard.
201
+
202
+ **Test vectors**: [`vectors/aauth-budgets-vectors.json`](vectors/aauth-budgets-vectors.json) —
203
+ header serializations (including `required` and the cost-omitted streaming
204
+ case), the budget object, consumption records, and a fully worked signed
205
+ usage response with a fixed key, ready for cross-implementation checks.
206
+
141
207
  ## Security model (what a naive implementation gets wrong)
142
208
 
143
209
  The verifier fetches key directories from **attacker-nameable origins** — whoever signs a
@@ -108,6 +108,72 @@ keyid-less shape is pinned in CI.
108
108
  [christian-posta/aauth-python-library](https://github.com/christian-posta/aauth-python-library) —
109
109
  this library is the thin relying-party verifier that handles both dialects.
110
110
 
111
+ ## Budgets: meter a spending envelope (draft-hardt-aauth-budgets)
112
+
113
+ An agent can carry a PS-issued **auth token** (`typ: aa-auth+jwt`) with a
114
+ `budget` claim — a spending envelope it uses offline, no per-call round trip
115
+ to the control plane. The middleware does the whole resource-side checklist:
116
+ verify the token against your pinned PS, atomically reserve → commit →
117
+ release per request, answer with `AAuth-Budget`, and refuse exhausted
118
+ envelopes with a `401` + `AAuth-Requirement` (optionally carrying your signed
119
+ resource token with the agent's own consumption records — scoped to its key,
120
+ so one agent never learns about a sibling's spending):
121
+
122
+ ```python
123
+ from regent_httpsig import HttpsigConfig, HttpsigVerifier, InMemoryMeter
124
+ from regent_httpsig.fastapi import BudgetMiddleware
125
+
126
+ app.add_middleware(
127
+ BudgetMiddleware,
128
+ verifier=HttpsigVerifier(HttpsigConfig(
129
+ resource_url="https://api.example",
130
+ trusted_ps={"my-ps": "https://ps.example/jwks.json"},
131
+ )),
132
+ meter=InMemoryMeter(),
133
+ price_fn=lambda request: PRICES.get(request.url.path), # max cost, minor units
134
+ )
135
+ ```
136
+
137
+ The only thing the library cannot do for you is pricing (`price_fn`) — that
138
+ is your domain. First known implementation of the draft; running in
139
+ production on [get4agent.com](https://get4agent.com).
140
+
141
+ **The August 20 additions** are covered too:
142
+
143
+ - `insufficient-budget` refusals carry **`required`** — the refused request's
144
+ maximum cost — so the agent lowers its bound and retries instead of guessing.
145
+ - **Streaming** responses run in the draft's cost-omitted mode: `reserved` in
146
+ the header, commit when the stream ends (set `request.state.budget_cost`
147
+ mid-stream if you learn the actual), and the agent recovers the exact cost
148
+ from the next response's `remaining`.
149
+ - The **usage endpoint** (§Usage Counters) lets your PS query consumption
150
+ without the agent in the loop — scope counters (`sub`, UTC calendar buckets)
151
+ and per-key `jkts` totals, optionally signed:
152
+
153
+ ```python
154
+ from regent_httpsig import InMemoryMeter, ResponseSigner, make_usage_endpoint
155
+
156
+ handler = make_usage_endpoint(
157
+ meter,
158
+ authenticate_ps=my_ps_authenticator, # verify the PS's jwks_uri signature
159
+ unit="USD", decimals=6,
160
+ signer=ResponseSigner(seed=SEED, jwks_url="https://api.example/jwks.json"),
161
+ )
162
+
163
+ @app.post("/usage")
164
+ async def usage(request: Request):
165
+ return await handler(request)
166
+ ```
167
+
168
+ - `validate_budget_grant(unit, decimals, budget_units)` enforces the resource
169
+ metadata MUSTs before you mint a resource token — the "thousandfold error"
170
+ guard.
171
+
172
+ **Test vectors**: [`vectors/aauth-budgets-vectors.json`](vectors/aauth-budgets-vectors.json) —
173
+ header serializations (including `required` and the cost-omitted streaming
174
+ case), the budget object, consumption records, and a fully worked signed
175
+ usage response with a fixed key, ready for cross-implementation checks.
176
+
111
177
  ## Security model (what a naive implementation gets wrong)
112
178
 
113
179
  The verifier fetches key directories from **attacker-nameable origins** — whoever signs a
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "regent-httpsig"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -4,26 +4,58 @@ Web Bot Auth (what OpenAI ships, what Cloudflare/AWS/Google verify) and AAuth
4
4
  (draft-hardt), in plain Python. See https://github.com/regent-protocol/regent-httpsig
5
5
  """
6
6
 
7
+ from regent_httpsig.budget import (
8
+ BudgetClaim,
9
+ InMemoryMeter,
10
+ InsufficientBudget,
11
+ InvalidBudgetClaim,
12
+ UnitMismatch,
13
+ )
7
14
  from regent_httpsig.config import HttpsigConfig
8
15
  from regent_httpsig.jwk import b64url, jwk_thumbprint, load_ed25519_jwk
9
16
  from regent_httpsig.netguard import NotPublicURL, assert_public_url
10
- from regent_httpsig.sfv import parse_signature_agent
17
+ from regent_httpsig.usage import (
18
+ ResponseSigner,
19
+ UsageQueryError,
20
+ build_usage_response,
21
+ make_usage_endpoint,
22
+ parse_usage_request,
23
+ validate_budget_grant,
24
+ )
25
+ from regent_httpsig.sfv import (
26
+ build_aauth_budget_header,
27
+ build_aauth_requirement,
28
+ parse_signature_agent,
29
+ )
11
30
  from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
12
31
  from regent_httpsig.verify import WBA_TAG, HttpsigVerifier, VerifiedSignature
13
32
 
14
- __version__ = "0.2.0"
33
+ __version__ = "0.3.0"
15
34
 
16
35
  __all__ = [
17
36
  "DIRECTORY_MEDIA_TYPE",
37
+ "BudgetClaim",
18
38
  "EgressSigner",
19
39
  "HttpsigConfig",
20
40
  "HttpsigVerifier",
41
+ "InMemoryMeter",
42
+ "InsufficientBudget",
43
+ "InvalidBudgetClaim",
21
44
  "NotPublicURL",
45
+ "UnitMismatch",
22
46
  "VerifiedSignature",
23
47
  "WBA_TAG",
24
48
  "__version__",
25
49
  "assert_public_url",
26
50
  "b64url",
51
+ "ResponseSigner",
52
+ "UsageQueryError",
53
+ "build_usage_response",
54
+ "make_usage_endpoint",
55
+ "parse_usage_request",
56
+ "validate_budget_grant",
57
+ "build_aauth_budget_header",
58
+ "build_aauth_requirement",
27
59
  "generate_seed",
28
60
  "jwk_thumbprint",
29
61
  "load_ed25519_jwk",
@@ -0,0 +1,345 @@
1
+ """AAuth Budgets (draft-hardt-aauth-budgets, editor's copy) — resource-side core.
2
+
3
+ The auth token carries a spending envelope::
4
+
5
+ "budget": { "amount": 2000000, "unit": "USD", "decimals": 6 } # = $2.00
6
+
7
+ and the resource meters every request against it: reserve the request's maximum
8
+ cost atomically, serve, commit the actual cost, release the difference. The
9
+ draft requires consumption to be aggregated atomically across all live auth
10
+ tokens for the key ``(iss, sub, aud)`` (§14.4), so the meter pools the grants
11
+ of a principal's live tokens and counts reservations + consumption against
12
+ that pool.
13
+
14
+ Everything here is framework-free; the FastAPI glue lives in
15
+ :mod:`regent_httpsig.fastapi` (``BudgetMiddleware``).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import itertools
22
+ import time
23
+ from collections.abc import Mapping
24
+ from datetime import UTC, datetime, timedelta
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+
28
+ __all__ = [
29
+ "BudgetClaim",
30
+ "InMemoryMeter",
31
+ "InsufficientBudget",
32
+ "InvalidBudgetClaim",
33
+ "Reservation",
34
+ "UnitMismatch",
35
+ ]
36
+
37
+ MeterKey = tuple[str, str, str] # (iss, sub, aud) — the draft's aggregation key
38
+
39
+
40
+ class InvalidBudgetClaim(ValueError):
41
+ """A ``budget`` member is present but malformed (issuer bug — not spendable)."""
42
+
43
+
44
+ class UnitMismatch(ValueError):
45
+ """A grant's unit/decimals differ from the pool's — one envelope, one unit."""
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class BudgetClaim:
50
+ """The ``budget`` claim: integer amount in ``unit`` scaled by ``decimals``.
51
+
52
+ ``amount=5000000, unit="USD", decimals=6`` is $5.00 — all arithmetic stays
53
+ in integers; the scale only matters at display time.
54
+ """
55
+
56
+ amount: int
57
+ unit: str
58
+ decimals: int
59
+
60
+ @staticmethod
61
+ def parse(claims: Mapping[str, Any]) -> BudgetClaim | None:
62
+ """Extract the claim from a token's claim set. ``None`` when absent;
63
+ :class:`InvalidBudgetClaim` when present but malformed (all three
64
+ members are REQUIRED, integers must be non-negative, bools are not
65
+ integers here)."""
66
+ raw = claims.get("budget")
67
+ if raw is None:
68
+ return None
69
+ if not isinstance(raw, Mapping):
70
+ raise InvalidBudgetClaim("budget claim must be an object")
71
+ amount, unit, decimals = raw.get("amount"), raw.get("unit"), raw.get("decimals")
72
+ if (
73
+ isinstance(amount, bool) or not isinstance(amount, int) or amount < 0
74
+ or not isinstance(unit, str) or not unit
75
+ or isinstance(decimals, bool) or not isinstance(decimals, int) or decimals < 0
76
+ ):
77
+ raise InvalidBudgetClaim("budget claim requires amount/unit/decimals")
78
+ return BudgetClaim(amount=amount, unit=unit, decimals=decimals)
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class Reservation:
83
+ """An atomic hold on the pool for one in-flight request. Never revised —
84
+ committed (with the actual cost) or released, exactly once."""
85
+
86
+ rid: int
87
+ key: MeterKey
88
+ jti: str
89
+ amount: int
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class InsufficientBudget:
94
+ """Refusal: the request's maximum cost exceeds the pool's remaining balance.
95
+ ``exhausted`` distinguishes the draft's two reason tokens: an empty envelope
96
+ (``budget-exhausted``) vs a too-expensive request (``insufficient-budget``)."""
97
+
98
+ remaining: int
99
+ exhausted: bool
100
+
101
+
102
+ @dataclass
103
+ class _Pool:
104
+ unit: str
105
+ decimals: int
106
+ grants: dict[str, tuple[int, float]] = field(default_factory=dict) # jti -> (amount, exp)
107
+ consumed: dict[str, int] = field(default_factory=dict) # jti -> total committed
108
+ jkt_of: dict[str, str] = field(default_factory=dict) # jti -> presenting key thumbprint
109
+ reservations: dict[int, tuple[str, int, float]] = field(default_factory=dict)
110
+ last_activity: float = 0.0
111
+
112
+
113
+ @dataclass
114
+ class _ScopeCounters:
115
+ """Calendar counters for one usage scope (draft §calendar-counters): running
116
+ integers bucketed on UTC boundaries — no per-record history is kept."""
117
+
118
+ all_time: int = 0
119
+ day: int = 0
120
+ week: int = 0
121
+ month: int = 0
122
+ year: int = 0
123
+ day_start: float = 0.0
124
+ week_start: float = 0.0
125
+ month_start: float = 0.0
126
+ year_start: float = 0.0
127
+
128
+ def add(self, amount: int, wall: float) -> None:
129
+ self._roll(wall)
130
+ self.all_time += amount
131
+ self.day += amount
132
+ self.week += amount
133
+ self.month += amount
134
+ self.year += amount
135
+
136
+ def snapshot(self, wall: float) -> dict[str, int]:
137
+ self._roll(wall)
138
+ return {"day": self.day, "week": self.week, "month": self.month,
139
+ "year": self.year, "all_time": self.all_time}
140
+
141
+ def _roll(self, wall: float) -> None:
142
+ dt = datetime.fromtimestamp(wall, tz=UTC)
143
+ day = dt.replace(hour=0, minute=0, second=0, microsecond=0)
144
+ week = day - timedelta(days=dt.weekday()) # Monday 00:00 UTC (ISO 8601)
145
+ month = day.replace(day=1)
146
+ year = month.replace(month=1)
147
+ for name, start in (("day", day), ("week", week),
148
+ ("month", month), ("year", year)):
149
+ ts = start.timestamp()
150
+ if getattr(self, f"{name}_start") < ts:
151
+ setattr(self, name, 0)
152
+ setattr(self, f"{name}_start", ts)
153
+
154
+
155
+ class InMemoryMeter:
156
+ """Single-process meter (asyncio-safe). Right for a single-instance service;
157
+ multi-replica deployments need a shared backend behind the same interface.
158
+
159
+ Crash-safety is conservative: a reservation not committed or released within
160
+ ``reservation_ttl`` seconds is treated as fully consumed — the owner's
161
+ envelope is never silently under-counted by a crashed handler.
162
+ """
163
+
164
+ def __init__(self, *, reservation_ttl: float = 120.0,
165
+ retention_seconds: float = 7200.0,
166
+ usage_key_retention: float = 86400.0) -> None:
167
+ self._pools: dict[MeterKey, _Pool] = {}
168
+ self._lock = asyncio.Lock()
169
+ self._rids = itertools.count(1)
170
+ self._reservation_ttl = reservation_ttl
171
+ self._retention = retention_seconds
172
+ # Usage counters (draft §usage-counters) — wall-clock, keyed by the
173
+ # issuing PS so the endpoint only answers the party whose tokens we
174
+ # accepted. Scope counters never expire (all_time reaches as far back
175
+ # as the resource retains); per-key figures are pruned on IDLE time —
176
+ # "SHOULD retain … at least 24 hours after that key's last metered
177
+ # request" — so a key in continuous use is never pruned.
178
+ self._usage_key_retention = usage_key_retention
179
+ self._scope_usage: dict[tuple[str, str], _ScopeCounters] = {} # (iss, sub)
180
+ self._key_usage: dict[tuple[str, str], tuple[int, float]] = {} # (iss, jkt) -> (total, last_wall)
181
+ self._metering_unit: tuple[str, int] | None = None
182
+
183
+ # ── internals (call under lock) ──────────────────────────────────────────
184
+
185
+ def _purge(self, key: MeterKey, now: float) -> _Pool | None:
186
+ pool = self._pools.get(key)
187
+ if pool is None:
188
+ return None
189
+ # Expired, unresolved reservations count as consumed (conservative).
190
+ for rid, (jti, amount, deadline) in list(pool.reservations.items()):
191
+ if deadline <= now:
192
+ pool.consumed[jti] = pool.consumed.get(jti, 0) + amount
193
+ del pool.reservations[rid]
194
+ self._record_usage(key, pool, jti, amount)
195
+ # Expired grants leave the pool; their consumption records remain for
196
+ # budget_consumed reporting until the retention window passes.
197
+ for jti, (_, exp) in list(pool.grants.items()):
198
+ if exp <= now:
199
+ del pool.grants[jti]
200
+ if (not pool.grants and not pool.reservations
201
+ and now - pool.last_activity > self._retention):
202
+ del self._pools[key]
203
+ return None
204
+ return pool
205
+
206
+ @staticmethod
207
+ def _remaining(pool: _Pool) -> int:
208
+ live = sum(a for a, _ in pool.grants.values())
209
+ spent = sum(pool.consumed.get(jti, 0) for jti in pool.grants)
210
+ held = sum(a for _, a, _ in pool.reservations.values())
211
+ return max(0, live - spent - held)
212
+
213
+ # ── public interface (the BudgetMeter contract) ──────────────────────────
214
+
215
+ async def observe_grant(self, key: MeterKey, jti: str, claim: BudgetClaim,
216
+ exp: float, jkt: str = "") -> None:
217
+ """Register a token's envelope in the principal's pool (idempotent per
218
+ ``jti``). ``jkt`` is the RFC 7638 thumbprint of the token's ``cnf`` key —
219
+ recorded so consumption records can be scoped to the presenting agent
220
+ (one agent must not learn about its siblings). Raises
221
+ :class:`UnitMismatch` if the pool already runs in a different unit —
222
+ one envelope, one unit, no FX at the meter."""
223
+ async with self._lock:
224
+ now = time.monotonic()
225
+ wall_delta = exp - time.time()
226
+ pool = self._purge(key, now)
227
+ if pool is None:
228
+ pool = self._pools.setdefault(
229
+ key, _Pool(unit=claim.unit, decimals=claim.decimals))
230
+ if (pool.unit, pool.decimals) != (claim.unit, claim.decimals):
231
+ raise UnitMismatch(
232
+ f"pool runs in {pool.unit}/{pool.decimals}, "
233
+ f"grant is {claim.unit}/{claim.decimals}")
234
+ pool.last_activity = now
235
+ if jkt:
236
+ pool.jkt_of.setdefault(jti, jkt)
237
+ if jti not in pool.grants and wall_delta > 0:
238
+ pool.grants[jti] = (claim.amount, now + wall_delta)
239
+
240
+ async def reserve(self, key: MeterKey, jti: str,
241
+ max_cost: int) -> Reservation | InsufficientBudget:
242
+ async with self._lock:
243
+ now = time.monotonic()
244
+ pool = self._purge(key, now)
245
+ if pool is None or jti not in pool.grants:
246
+ return InsufficientBudget(remaining=0, exhausted=True)
247
+ remaining = self._remaining(pool)
248
+ if max_cost > remaining:
249
+ return InsufficientBudget(remaining=remaining,
250
+ exhausted=remaining == 0)
251
+ rid = next(self._rids)
252
+ pool.reservations[rid] = (jti, max_cost, now + self._reservation_ttl)
253
+ pool.last_activity = now
254
+ return Reservation(rid=rid, key=key, jti=jti, amount=max_cost)
255
+
256
+ async def commit(self, res: Reservation, actual: int) -> int:
257
+ """Commit the actual cost (clamped to the reserved amount — reservations
258
+ are never revised upward) and return the pool's remaining balance."""
259
+ async with self._lock:
260
+ now = time.monotonic()
261
+ pool = self._purge(res.key, now)
262
+ if pool is None:
263
+ return 0
264
+ held = pool.reservations.pop(res.rid, None)
265
+ cost = min(max(actual, 0), held[1] if held else res.amount)
266
+ pool.consumed[res.jti] = pool.consumed.get(res.jti, 0) + cost
267
+ pool.last_activity = now
268
+ if cost > 0:
269
+ self._record_usage(res.key, pool, res.jti, cost)
270
+ return self._remaining(pool)
271
+
272
+ async def release(self, res: Reservation) -> int:
273
+ async with self._lock:
274
+ pool = self._purge(res.key, time.monotonic())
275
+ if pool is None:
276
+ return 0
277
+ pool.reservations.pop(res.rid, None)
278
+ return self._remaining(pool)
279
+
280
+ async def remaining(self, key: MeterKey) -> int:
281
+ async with self._lock:
282
+ pool = self._purge(key, time.monotonic())
283
+ return 0 if pool is None else self._remaining(pool)
284
+
285
+ def _record_usage(self, key: MeterKey, pool: _Pool, jti: str,
286
+ amount: int) -> None:
287
+ """Post a committed cost to the usage counters (call under lock).
288
+ Wall-clock, because calendar boundaries are UTC by definition."""
289
+ if amount <= 0:
290
+ return
291
+ wall = time.time()
292
+ iss, sub, _aud = key
293
+ self._metering_unit = self._metering_unit or (pool.unit, pool.decimals)
294
+ self._scope_usage.setdefault((iss, sub), _ScopeCounters()).add(amount, wall)
295
+ jkt = pool.jkt_of.get(jti)
296
+ if jkt:
297
+ total, _ = self._key_usage.get((iss, jkt), (0, 0.0))
298
+ self._key_usage[(iss, jkt)] = (total + amount, wall)
299
+
300
+ async def usage_scope(self, iss: str, sub: str) -> dict[str, int] | None:
301
+ """Calendar counters for a ``sub`` scope query, or ``None`` when the
302
+ resource holds no figure — the endpoint then omits ``usage``, keeping
303
+ "never seen" indistinguishable from "nothing consumed"."""
304
+ async with self._lock:
305
+ counters = self._scope_usage.get((iss, sub))
306
+ return None if counters is None else counters.snapshot(time.time())
307
+
308
+ async def usage_keys(self, iss: str, jkts: list[str]) -> dict[str, int]:
309
+ """Per-key totals for a ``jkts`` query. Unrecognized or pruned keys are
310
+ OMITTED, never reported as zero — absence means "cannot answer", a
311
+ present zero would be a wrong answer to an allocation decision."""
312
+ async with self._lock:
313
+ wall = time.time()
314
+ for pair, (_, last) in list(self._key_usage.items()):
315
+ if wall - last > self._usage_key_retention:
316
+ del self._key_usage[pair]
317
+ return {jkt: self._key_usage[(iss, jkt)][0]
318
+ for jkt in jkts if (iss, jkt) in self._key_usage}
319
+
320
+ def metering_unit(self) -> tuple[str, int] | None:
321
+ """The one unit every usage figure is denominated in (draft §one-unit),
322
+ or ``None`` before the first commit."""
323
+ return self._metering_unit
324
+
325
+ async def consumed_records(self, key: MeterKey,
326
+ jkt: str | None = None) -> list[dict[str, Any]]:
327
+ """Per-token consumption for the resource token's ``budget_consumed``
328
+ claim: ``[{"jti": ..., "consumed": ...}, ...]``. Non-destructive — the
329
+ PS deduplicates by ``jti``, so reporting the same record twice is safe.
330
+
331
+ When ``jkt`` is given, records are scoped to tokens bound to that key:
332
+ the agent carrying the resource token sees only its OWN spending, never
333
+ its siblings' (privacy between a principal's agents, and no extra
334
+ figures to infer the ceiling from). Consequence: an abandoned agent's
335
+ records are never carried home by siblings — the PS-side conservative
336
+ rule (unreported expired allocation = fully consumed) is the backstop."""
337
+ async with self._lock:
338
+ pool = self._purge(key, time.monotonic())
339
+ if pool is None:
340
+ return []
341
+ return [
342
+ {"jti": jti, "consumed": total}
343
+ for jti, total in sorted(pool.consumed.items())
344
+ if total > 0 and (jkt is None or pool.jkt_of.get(jti) == jkt)
345
+ ]