booking-agent-cli 0.1.0__py3-none-any.whl

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 (74) hide show
  1. booking_agent/__init__.py +6 -0
  2. booking_agent/authorization.py +450 -0
  3. booking_agent/backend/__init__.py +217 -0
  4. booking_agent/backend/live.py +494 -0
  5. booking_agent/calendar/__init__.py +55 -0
  6. booking_agent/calendar/filter.py +41 -0
  7. booking_agent/calendar/google.py +582 -0
  8. booking_agent/calendar/oauth_listener.py +287 -0
  9. booking_agent/calendar/scope.py +97 -0
  10. booking_agent/circuit_breaker.py +116 -0
  11. booking_agent/config.py +51 -0
  12. booking_agent/defaults.env +50 -0
  13. booking_agent/discovery.py +188 -0
  14. booking_agent/headless.py +463 -0
  15. booking_agent/http_deadlines.py +43 -0
  16. booking_agent/http_retry.py +178 -0
  17. booking_agent/http_trace.py +428 -0
  18. booking_agent/merchant_pacing.py +73 -0
  19. booking_agent/models/__init__.py +14 -0
  20. booking_agent/models/platform.py +200 -0
  21. booking_agent/models/wire.py +868 -0
  22. booking_agent/namespace.py +53 -0
  23. booking_agent/observability.py +143 -0
  24. booking_agent/platform_profile.py +120 -0
  25. booking_agent/pop.py +347 -0
  26. booking_agent/profile_cache.py +130 -0
  27. booking_agent/registry_url.py +35 -0
  28. booking_agent/rest_client.py +1493 -0
  29. booking_agent/secure_file.py +52 -0
  30. booking_agent/settings.py +110 -0
  31. booking_agent/signals.py +130 -0
  32. booking_agent/step_timing.py +168 -0
  33. booking_agent/ucp_capabilities.py +148 -0
  34. booking_agent/ucp_checkout.py +1363 -0
  35. booking_agent/ucp_negotiation.py +184 -0
  36. booking_agent/ucp_profile.py +410 -0
  37. booking_agent/usp_methods.py +110 -0
  38. booking_agent_cli/__init__.py +3 -0
  39. booking_agent_cli/activity_log.py +106 -0
  40. booking_agent_cli/booking_poll.py +141 -0
  41. booking_agent_cli/booking_summary.py +151 -0
  42. booking_agent_cli/calendar_connection.py +196 -0
  43. booking_agent_cli/calendar_event.py +611 -0
  44. booking_agent_cli/calendar_preference.py +176 -0
  45. booking_agent_cli/catalog.py +864 -0
  46. booking_agent_cli/checkout_prereq.py +67 -0
  47. booking_agent_cli/commands/__init__.py +0 -0
  48. booking_agent_cli/commands/config.py +106 -0
  49. booking_agent_cli/commands/flow.py +6988 -0
  50. booking_agent_cli/commands/lowlevel.py +163 -0
  51. booking_agent_cli/commands/runtime.py +46 -0
  52. booking_agent_cli/config_loader.py +108 -0
  53. booking_agent_cli/engine.py +168 -0
  54. booking_agent_cli/flow_timings.py +34 -0
  55. booking_agent_cli/geo_eligibility.py +183 -0
  56. booking_agent_cli/geocode.py +233 -0
  57. booking_agent_cli/identity.py +104 -0
  58. booking_agent_cli/link_cli.py +620 -0
  59. booking_agent_cli/main.py +75 -0
  60. booking_agent_cli/output.py +199 -0
  61. booking_agent_cli/payment_helpers.py +884 -0
  62. booking_agent_cli/payment_token.py +23 -0
  63. booking_agent_cli/reschedule.py +137 -0
  64. booking_agent_cli/runtime.py +55 -0
  65. booking_agent_cli/runtime_prepare.py +129 -0
  66. booking_agent_cli/search_area.py +111 -0
  67. booking_agent_cli/session.py +837 -0
  68. booking_agent_cli/support_bundle_schema.py +557 -0
  69. booking_agent_cli/support_report.py +1210 -0
  70. booking_agent_cli/timezone_context.py +314 -0
  71. booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
  72. booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
  73. booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  74. booking_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,53 @@
1
+ """USP protocol namespace authority (specification Section 2.5).
2
+
3
+ Capability keys are ``dev.usp-protocol.*``. ``spec`` and ``schema`` URLs for
4
+ those keys MUST use origin ``https://usp-protocol.dev``.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+ from urllib.parse import urlparse
10
+
11
+ AUTHORITY_HOST = "usp-protocol.dev"
12
+ AUTHORITY_ORIGIN = f"https://{AUTHORITY_HOST}"
13
+
14
+ CAPABILITY_PREFIX = "dev.usp-protocol."
15
+ SVC_SERVICES = "dev.usp-protocol.services"
16
+ CAP_CATALOG = "dev.usp-protocol.services.catalog"
17
+ CAP_AVAILABILITY = "dev.usp-protocol.services.availability"
18
+ CAP_BOOKINGS = "dev.usp-protocol.services.bookings"
19
+ CAP_PAID_BOOKINGS = "dev.usp-protocol.services.paid_bookings"
20
+
21
+
22
+ class NamespaceOriginError(ValueError):
23
+ """Raised when a ``dev.usp-protocol.*`` key is bound to a non-authority origin."""
24
+
25
+
26
+ def _origin(url: str) -> str:
27
+ parsed = urlparse(url)
28
+ if parsed.scheme and parsed.netloc:
29
+ return f"{parsed.scheme}://{parsed.netloc}"
30
+ return ""
31
+
32
+
33
+ def requires_authority_origin(key: str) -> bool:
34
+ return key == SVC_SERVICES or key.startswith(CAPABILITY_PREFIX)
35
+
36
+
37
+ def check_origin_binding(
38
+ key: str,
39
+ *,
40
+ spec: Optional[str] = None,
41
+ schema: Optional[str] = None,
42
+ ) -> None:
43
+ """Enforce Section 2.5 origin binding when *spec* or *schema* is present."""
44
+ if not requires_authority_origin(key):
45
+ return
46
+ for label, url in (("spec", spec), ("schema", schema)):
47
+ if not url:
48
+ continue
49
+ origin = _origin(url)
50
+ if origin != AUTHORITY_ORIGIN:
51
+ raise NamespaceOriginError(
52
+ f"{key} {label} origin must be {AUTHORITY_ORIGIN!r}, got {url!r}"
53
+ )
@@ -0,0 +1,143 @@
1
+ """Flow correlation id propagation and machine-countable money-path events."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ import uuid
7
+ from contextvars import ContextVar, Token
8
+ from typing import Any, Callable, Dict, Optional
9
+
10
+ import httpx
11
+
12
+ # Wire name for the flow join key. Optional on the USP HTTP binding (Track S of
13
+ # spec issue #160): send when bound, never require it on responses, never fail a
14
+ # call because a peer omitted or ignored it.
15
+ HEADER = "USP-Correlation-Id"
16
+
17
+ _FORBIDDEN_FIELD_KEYS = frozenset({
18
+ "token",
19
+ "credential",
20
+ "shared_payment_token",
21
+ "client_secret",
22
+ "access_token",
23
+ "refresh_token",
24
+ "email",
25
+ "phone",
26
+ "phone_number",
27
+ "first_name",
28
+ "last_name",
29
+ "name",
30
+ "address",
31
+ "authorization",
32
+ "dpop",
33
+ "spt",
34
+ "pan",
35
+ })
36
+
37
+ _correlation_id: ContextVar[Optional[str]] = ContextVar("booking_agent_correlation_id", default=None)
38
+ _event_sink: Callable[[Dict[str, Any]], None] = lambda record: sys.stderr.write(
39
+ json.dumps(record, default=str) + "\n"
40
+ )
41
+
42
+
43
+ def set_event_sink(sink: Callable[[Dict[str, Any]], None]) -> None:
44
+ """Replace the default stderr JSON event sink (tests)."""
45
+ global _event_sink
46
+ _event_sink = sink
47
+
48
+
49
+ def reset_event_sink() -> None:
50
+ """Restore the default stderr event sink."""
51
+ global _event_sink
52
+ _event_sink = lambda record: sys.stderr.write(json.dumps(record, default=str) + "\n")
53
+
54
+
55
+ def new_correlation_id() -> str:
56
+ return str(uuid.uuid4())
57
+
58
+
59
+ def bind(correlation_id: str) -> Token:
60
+ return _correlation_id.set(correlation_id)
61
+
62
+
63
+ def current() -> Optional[str]:
64
+ return _correlation_id.get()
65
+
66
+
67
+ def reset(token: Optional[Token] = None) -> None:
68
+ if token is not None:
69
+ _correlation_id.reset(token)
70
+ else:
71
+ _correlation_id.set(None)
72
+
73
+
74
+ def _sanitize_fields(fields: Dict[str, Any]) -> Dict[str, Any]:
75
+ out: Dict[str, Any] = {}
76
+ for key, value in fields.items():
77
+ key_l = str(key).lower()
78
+ if key_l in _FORBIDDEN_FIELD_KEYS or "credential" in key_l or key_l.endswith("_token"):
79
+ continue
80
+ if isinstance(value, str) and ("?" in value and ("http://" in value or "https://" in value)):
81
+ continue
82
+ out[key] = value
83
+ return out
84
+
85
+
86
+ def emit_event(event: str, **fields: Any) -> None:
87
+ """Emit one machine-countable JSON event line."""
88
+ record: Dict[str, Any] = {"event": event}
89
+ cid = current()
90
+ if cid:
91
+ record["correlation_id"] = cid
92
+ record.update(_sanitize_fields(fields))
93
+ _event_sink(record)
94
+
95
+
96
+ def registry_error_class(response: Optional[httpx.Response]) -> str:
97
+ """Map registry HTTP failures to #171-style classes."""
98
+ if response is None:
99
+ return "upstream_failure"
100
+ status = response.status_code
101
+ if status in {401, 403}:
102
+ return "auth_rejection"
103
+ if status in {400, 422}:
104
+ return "malformed_request"
105
+ if status >= 500:
106
+ return "upstream_failure"
107
+ try:
108
+ body = response.json()
109
+ except Exception:
110
+ body = None
111
+ if isinstance(body, dict):
112
+ err_type = str(body.get("type") or "").lower()
113
+ if "auth" in err_type or "unauthorized" in err_type:
114
+ return "auth_rejection"
115
+ if "malformed" in err_type or "invalid" in err_type:
116
+ return "malformed_request"
117
+ if status >= 400:
118
+ return "unknown"
119
+ return "unknown"
120
+
121
+
122
+ def payment_decline_reason_class(status: int, body: str = "") -> str:
123
+ """Classify UCP/processor declines for ``payment_declined`` events."""
124
+ lowered = (body or "").lower()
125
+ if status in {402, 409} or "declin" in lowered:
126
+ return "declined"
127
+ if status >= 500 or "processor" in lowered:
128
+ return "processor_error"
129
+ return "unknown"
130
+
131
+
132
+ def money_path_fields(
133
+ *,
134
+ spend_request_id: Optional[str] = None,
135
+ booking_id: Optional[str] = None,
136
+ ) -> Dict[str, Any]:
137
+ """Bounded join keys for money-path events (log fields, not metric labels)."""
138
+ fields: Dict[str, Any] = {}
139
+ if spend_request_id:
140
+ fields["spend_request_id"] = spend_request_id
141
+ if booking_id:
142
+ fields["booking_id"] = booking_id
143
+ return fields
@@ -0,0 +1,120 @@
1
+ """Canonical USP/UCP platform profile (embedded SoT for hosting + negotiation)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict
5
+
6
+ # Canonical publish URL: https://profile.booking-agent.dev/platform-profile.json
7
+ DEFAULT_PLATFORM_PROFILE_URL = "https://profile.booking-agent.dev/platform-profile.json"
8
+
9
+
10
+ def platform_profile_url() -> str:
11
+ """Public identity URL sent in both USP and UCP Agent headers.
12
+
13
+ ``USP_AGENT_PROFILE_URL`` is the only override. Development may point it at
14
+ loopback, but that is not conformant with the RFC 6890 and DNS-rebinding
15
+ checks required of production businesses.
16
+ """
17
+ from . import settings
18
+
19
+ return (settings.get("USP_AGENT_PROFILE_URL") or "").strip() or DEFAULT_PLATFORM_PROFILE_URL
20
+
21
+
22
+ def platform_profile_doc() -> Dict[str, Any]:
23
+ """Canonical platform profile document (single source of truth).
24
+
25
+ Generated bytes are published as ``platform-profile.json`` on the ``main``
26
+ branch of ``wix/booking-agent``. Keep this keyless (no ``keys`` / ``signing_keys`` /
27
+ ``webhook_url``).
28
+ """
29
+ return {
30
+ "usp": {
31
+ "version": "2026-08-14",
32
+ "capabilities": {
33
+ "dev.usp-protocol.services.catalog": [
34
+ {
35
+ "version": "2026-08-14",
36
+ "spec": "https://usp-protocol.dev/specification#3-service-catalog",
37
+ "schema": "https://usp-protocol.dev/schemas/services/catalog.json",
38
+ }
39
+ ],
40
+ "dev.usp-protocol.services.availability": [
41
+ {
42
+ "version": "2026-08-14",
43
+ "spec": "https://usp-protocol.dev/specification#4-availability",
44
+ "schema": "https://usp-protocol.dev/schemas/services/availability.json",
45
+ }
46
+ ],
47
+ "dev.usp-protocol.services.bookings": [
48
+ {
49
+ "version": "2026-08-14",
50
+ "spec": "https://usp-protocol.dev/specification#5-booking-lifecycle",
51
+ "schema": "https://usp-protocol.dev/schemas/services/booking.json",
52
+ }
53
+ ],
54
+ },
55
+ "services": {
56
+ "dev.usp-protocol.services": [{"transport": "rest"}],
57
+ },
58
+ },
59
+ "ucp": {
60
+ "version": "2026-08-25",
61
+ "supported_versions": ["2026-04-08", "2026-08-25"],
62
+ "services": {},
63
+ "capabilities": {
64
+ "dev.ucp.shopping.checkout": [
65
+ {
66
+ "version": "2026-08-25",
67
+ "spec": "https://ucp.dev/latest/specification/shopping/checkout/",
68
+ "schema": "https://ucp.dev/latest/schemas/shopping/checkout.json",
69
+ },
70
+ {
71
+ "version": "2026-04-08",
72
+ "spec": "https://ucp.dev/latest/specification/shopping/checkout/",
73
+ "schema": "https://ucp.dev/latest/schemas/shopping/checkout.json",
74
+ },
75
+ ],
76
+ "dev.ucp.shopping.order": [
77
+ {
78
+ "version": "2026-08-25",
79
+ "spec": "https://ucp.dev/latest/specification/shopping/order",
80
+ "schema": "https://ucp.dev/latest/schemas/shopping/order.json",
81
+ }
82
+ ],
83
+ "dev.usp-protocol.services.paid_bookings": [
84
+ {
85
+ "version": "2026-08-20",
86
+ "spec": "https://usp-protocol.dev/specification#7-ucp-native-mode",
87
+ "schema": "https://usp-protocol.dev/schemas/services/paid_bookings.json",
88
+ "extends": "dev.ucp.shopping.checkout",
89
+ }
90
+ ],
91
+ "dev.ucp.common.payment.terms": [
92
+ {
93
+ "version": "2026-08-25",
94
+ "extends": [
95
+ "dev.ucp.shopping.checkout",
96
+ "dev.ucp.shopping.order",
97
+ ],
98
+ "spec": "https://ucp.dev/latest/specification/payment/extensions/terms/",
99
+ "schema": "https://ucp.dev/2026-08-25/schemas/common/payment_terms.json",
100
+ }
101
+ ],
102
+ },
103
+ "payment_handlers": {
104
+ "com.stripe.payments": [
105
+ {
106
+ "id": "stripe_payments",
107
+ "version": "2026-06-25",
108
+ "spec": (
109
+ "https://docs.stripe.com/agentic-commerce/"
110
+ "ucp/stripe-payments-handler"
111
+ ),
112
+ "schema": "https://ucp.stripe.com/payments/2026-06-25/schema.json",
113
+ "available_instruments": [
114
+ {"type": "link"},
115
+ ],
116
+ }
117
+ ],
118
+ },
119
+ },
120
+ }
booking_agent/pop.py ADDED
@@ -0,0 +1,347 @@
1
+ """``platform_key_pop`` -- proof-of-possession keys and proofs (USP Section 10.1.6).
2
+
3
+ The agent holds one Ed25519 keypair per business endpoint. The private half is
4
+ never transmitted; the public half travels only inside the JOSE header of each
5
+ proof. The business computes the RFC 7638 thumbprint of that key and binds the
6
+ issued ``booking_scoped_credential`` to it as ``cnf.jkt``, so a credential value
7
+ that leaks -- via a log, a trace, a TLS-terminating proxy, or a database
8
+ compromise -- is useless without the key.
9
+
10
+ What this module is deliberately *not*:
11
+
12
+ * It is not RFC 9421 request signing. No ``Content-Digest``, no signature base,
13
+ no covered components.
14
+ * It publishes nothing. The platform profile stays keyless and shared; there
15
+ are no ``keys``/``signing_keys``, no rotation, and no per-instance profiles.
16
+ * The key is not a platform identity. It is per-endpoint and self-generated, so
17
+ it identifies "the holder of this credential", nothing more.
18
+
19
+ "Ephemeral" here means *never published and never a platform identity* -- not
20
+ short-lived. The key must outlive every credential bound to it, or those
21
+ credentials become unusable; see ``PopKeyStore.release``.
22
+
23
+ Only the REST binding is implemented. The MCP binding (``typ: usp-pop+jwt``
24
+ with ``usp_m``/``aud``/``usp_p`` over JCS-canonicalized params) is specified but
25
+ has no client anywhere -- see issue #199. REST proofs carry the latest
26
+ ``DPoP-Nonce`` from any HTTP response; MCP, when built, must use
27
+ ``nonce_from_pop_error_body`` from ``authorization``.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import base64
32
+ import hashlib
33
+ import json
34
+ import logging
35
+ import time
36
+ import uuid
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Any, Dict, Optional
40
+ from urllib.parse import urlsplit, urlunsplit
41
+
42
+ import httpx
43
+ from cryptography.hazmat.primitives import serialization
44
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
45
+ Ed25519PrivateKey,
46
+ Ed25519PublicKey,
47
+ )
48
+
49
+ from . import settings
50
+ from .authorization import nonce_from_pop_error_body
51
+ from .secure_file import ensure_private_dir, write_private_text
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ _KEYSTORE_FILE_NAME = "pop_keys.json"
56
+ _STORE_VERSION = 1
57
+
58
+ #: The only algorithm this client emits. USP Section 10.1.6 makes EdDSA (Ed25519) the
59
+ #: floor that any business advertising ``platform_key_pop`` MUST support, chosen
60
+ #: to avoid the ECDSA fixed-width ``r||s`` vs DER interoperability trap.
61
+ ALG = "EdDSA"
62
+
63
+ #: REST proofs are standard RFC 9449 DPoP proofs. The MCP binding uses
64
+ #: ``usp-pop+jwt`` precisely so neither proof can be replayed as the other.
65
+ REST_PROOF_TYP = "dpop+jwt"
66
+
67
+ MECHANISM = "platform_key_pop"
68
+
69
+
70
+ def b64url(raw: bytes) -> str:
71
+ """base64url without padding, as every JOSE and RFC 7638 value is encoded."""
72
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
73
+
74
+
75
+ def compute_jkt(public_jwk: Dict[str, str]) -> str:
76
+ """RFC 7638 JWK SHA-256 thumbprint of an OKP/Ed25519 public key.
77
+
78
+ The hash input is the *required* members only, lexicographically ordered
79
+ (``crv``, ``kty``, ``x``), serialized with no whitespace. Any other member --
80
+ ``alg``, ``kid``, ``use`` -- must be excluded or the thumbprint will not
81
+ match what the business computes.
82
+ """
83
+ required = {
84
+ "crv": public_jwk["crv"],
85
+ "kty": public_jwk["kty"],
86
+ "x": public_jwk["x"],
87
+ }
88
+ canonical = json.dumps(required, separators=(",", ":"), sort_keys=True)
89
+ jkt = b64url(hashlib.sha256(canonical.encode("utf-8")).digest())
90
+ # schemas/usp.json#/$defs/ConfirmationKey pins this to exactly 43 chars.
91
+ if len(jkt) != 43:
92
+ raise ValueError(f"computed jkt has length {len(jkt)}, expected 43")
93
+ return jkt
94
+
95
+
96
+ def credential_ath(credential_value: str) -> str:
97
+ """RFC 9449 ``ath``: base64url(SHA-256(credential value)).
98
+
99
+ Binds a proof to the specific credential presented, so a proof captured
100
+ alongside one credential cannot be paired with another.
101
+ """
102
+ return b64url(hashlib.sha256(credential_value.encode("utf-8")).digest())
103
+
104
+
105
+ def normalize_htu(url: str) -> str:
106
+ """RFC 9449 ``htu``: the request URI without query or fragment."""
107
+ parts = urlsplit(url)
108
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class PopKey:
113
+ """An Ed25519 keypair and its derived public JWK and thumbprint."""
114
+
115
+ private_key: Ed25519PrivateKey
116
+ public_jwk: Dict[str, str]
117
+ jkt: str
118
+
119
+ @classmethod
120
+ def generate(cls) -> "PopKey":
121
+ return cls.from_private_bytes(Ed25519PrivateKey.generate())
122
+
123
+ @classmethod
124
+ def from_private_bytes(cls, private_key: Ed25519PrivateKey) -> "PopKey":
125
+ public_jwk = _public_jwk(private_key.public_key())
126
+ return cls(
127
+ private_key=private_key,
128
+ public_jwk=public_jwk,
129
+ jkt=compute_jkt(public_jwk),
130
+ )
131
+
132
+ @classmethod
133
+ def from_seed_b64(cls, seed_b64: str) -> "PopKey":
134
+ raw = base64.urlsafe_b64decode(seed_b64 + "=" * (-len(seed_b64) % 4))
135
+ return cls.from_private_bytes(Ed25519PrivateKey.from_private_bytes(raw))
136
+
137
+ def seed_b64(self) -> str:
138
+ raw = self.private_key.private_bytes(
139
+ encoding=serialization.Encoding.Raw,
140
+ format=serialization.PrivateFormat.Raw,
141
+ encryption_algorithm=serialization.NoEncryption(),
142
+ )
143
+ return b64url(raw)
144
+
145
+ def sign(self, message: bytes) -> bytes:
146
+ return self.private_key.sign(message)
147
+
148
+ def __repr__(self) -> str: # pragma: no cover - defensive
149
+ # Never let the private key reach a log line or a traceback.
150
+ return f"PopKey(jkt={self.jkt!r})"
151
+
152
+
153
+ def _public_jwk(public_key: Ed25519PublicKey) -> Dict[str, str]:
154
+ raw = public_key.public_bytes(
155
+ encoding=serialization.Encoding.Raw,
156
+ format=serialization.PublicFormat.Raw,
157
+ )
158
+ return {"kty": "OKP", "crv": "Ed25519", "x": b64url(raw)}
159
+
160
+
161
+ def build_rest_proof(
162
+ key: PopKey,
163
+ *,
164
+ htm: str,
165
+ htu: str,
166
+ ath: Optional[str] = None,
167
+ nonce: Optional[str] = None,
168
+ iat: Optional[int] = None,
169
+ jti: Optional[str] = None,
170
+ ) -> str:
171
+ """Build an RFC 9449 ``dpop+jwt`` proof as a compact JWS.
172
+
173
+ ``ath`` is absent at issuance (there is no credential yet) and REQUIRED at
174
+ presentation. ``jti`` is fresh per *attempt*, not per logical call -- a
175
+ retried request must carry a new one or the business's replay cache will
176
+ reject it as ``pop_proof_replayed``.
177
+
178
+ Hand-rolled rather than pulled from a JOSE library: the header is fixed, the
179
+ claim set is six fields, and a library would add a dependency whose
180
+ algorithm-negotiation defaults would need auditing to guarantee we never
181
+ emit ``alg: none``.
182
+ """
183
+ header = {
184
+ "typ": REST_PROOF_TYP,
185
+ "alg": ALG,
186
+ # The public JWK travels here and nowhere else. It carries no private
187
+ # member ('d'), which a business MUST reject.
188
+ "jwk": key.public_jwk,
189
+ }
190
+ claims: Dict[str, Any] = {
191
+ "htm": htm.upper(),
192
+ "htu": normalize_htu(htu),
193
+ "iat": int(time.time()) if iat is None else iat,
194
+ "jti": jti or str(uuid.uuid4()),
195
+ }
196
+ if ath is not None:
197
+ claims["ath"] = ath
198
+ if nonce is not None:
199
+ claims["nonce"] = nonce
200
+
201
+ signing_input = (
202
+ b64url(json.dumps(header, separators=(",", ":")).encode("utf-8"))
203
+ + "."
204
+ + b64url(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
205
+ )
206
+ signature = b64url(key.sign(signing_input.encode("ascii")))
207
+ return f"{signing_input}.{signature}"
208
+
209
+
210
+ def usp_error_payload(response: httpx.Response) -> Dict[str, Any]:
211
+ """Return the ``error`` object of a USP error body, or the body itself."""
212
+ try:
213
+ body = response.json()
214
+ except Exception:
215
+ return {}
216
+ if not isinstance(body, dict):
217
+ return {}
218
+ error = body.get("error")
219
+ return error if isinstance(error, dict) else body
220
+
221
+
222
+ def dpop_nonce_from_response(response: httpx.Response) -> Optional[str]:
223
+ """Return a non-empty ``DPoP-Nonce`` response header, if present.
224
+
225
+ Any response may rotate the nonce; an empty or missing header does not clear
226
+ the value already held.
227
+ """
228
+ nonce = response.headers.get("DPoP-Nonce")
229
+ return nonce if isinstance(nonce, str) and nonce else None
230
+
231
+
232
+ def nonce_challenge_from_401(response: httpx.Response) -> Optional[str]:
233
+ """Return the nonce a business is challenging for on HTTP 401.
234
+
235
+ REST (RFC 9449 / USP Section 10.1.6): ``DPoP-Nonce`` header first. JSON
236
+ ``proof_nonce_required`` / ``data.nonce`` is the MCP channel and REST
237
+ fallback when the header is absent.
238
+ """
239
+ header_nonce = dpop_nonce_from_response(response)
240
+ if header_nonce is not None:
241
+ return header_nonce
242
+ return nonce_from_pop_error_body(usp_error_payload(response))
243
+
244
+
245
+ def keystore_path() -> Path:
246
+ base = settings.booking_agent_home()
247
+ ensure_private_dir(base)
248
+ return base / _KEYSTORE_FILE_NAME
249
+
250
+
251
+ def endpoint_key(endpoint: str) -> str:
252
+ """Normalize a REST endpoint to the origin used to key the store.
253
+
254
+ Matches ``USPTokenStore._base_url`` so a business is one principal
255
+ regardless of which path the client happened to resolve.
256
+ """
257
+ parsed = urlsplit(endpoint)
258
+ return f"{parsed.scheme}://{parsed.netloc}"
259
+
260
+
261
+ class PopKeyStore:
262
+ """Per-business-endpoint Ed25519 keys, persisted 0600.
263
+
264
+ The key is per *endpoint*; credentials are per *resource*. That asymmetry is
265
+ the reason ``release`` is refcounted rather than a plain delete -- see its
266
+ docstring.
267
+ """
268
+
269
+ def __init__(self, path: Optional[Path] = None):
270
+ self._path = path or keystore_path()
271
+ self._entries: Dict[str, Dict[str, Any]] = {}
272
+ self._load()
273
+
274
+ def _load(self) -> None:
275
+ if not self._path.is_file():
276
+ return
277
+ try:
278
+ data = json.loads(self._path.read_text())
279
+ except (OSError, json.JSONDecodeError):
280
+ logger.warning("Failed to read PoP keystore at %s", self._path, exc_info=True)
281
+ return
282
+ if not isinstance(data, dict) or data.get("version") != _STORE_VERSION:
283
+ return
284
+ entries = data.get("keys")
285
+ if isinstance(entries, dict):
286
+ self._entries = entries
287
+
288
+ def _save(self) -> None:
289
+ payload = {"version": _STORE_VERSION, "keys": self._entries}
290
+ try:
291
+ write_private_text(self._path, json.dumps(payload, indent=2))
292
+ except OSError:
293
+ logger.warning("Failed to persist PoP keystore to %s", self._path, exc_info=True)
294
+
295
+ def get_or_create(self, endpoint: str) -> PopKey:
296
+ """Return the key for *endpoint*, generating one on first contact.
297
+
298
+ This is the whole of platform onboarding under ``platform_key_pop``:
299
+ local, with no network call and no registration round trip.
300
+ """
301
+ slot = endpoint_key(endpoint)
302
+ entry = self._entries.get(slot)
303
+ if entry and entry.get("seed"):
304
+ try:
305
+ return PopKey.from_seed_b64(entry["seed"])
306
+ except Exception:
307
+ logger.warning(
308
+ "Unreadable PoP key for %s; regenerating. Credentials bound to "
309
+ "the old key can no longer be used.", slot, exc_info=True,
310
+ )
311
+ key = PopKey.generate()
312
+ self._entries[slot] = {"seed": key.seed_b64(), "jkt": key.jkt}
313
+ self._save()
314
+ return key
315
+
316
+ def peek(self, endpoint: str) -> Optional[PopKey]:
317
+ """Return the existing key for *endpoint*, or None. Never generates."""
318
+ entry = self._entries.get(endpoint_key(endpoint))
319
+ if not entry or not entry.get("seed"):
320
+ return None
321
+ try:
322
+ return PopKey.from_seed_b64(entry["seed"])
323
+ except Exception:
324
+ return None
325
+
326
+ def release(self, endpoint: str, *, still_in_use: bool) -> bool:
327
+ """Drop the key for *endpoint* once nothing is bound to it any more.
328
+
329
+ Called when a resource reaches a terminal state. *still_in_use* must be
330
+ True if any other non-expired credential issued by that business remains
331
+ in the session store.
332
+
333
+ Destroying the key unconditionally would be a data-loss bug rather than
334
+ hygiene: with two live bookings at one business, cancelling the first
335
+ would permanently orphan the second, whose credential is bound to a
336
+ ``jkt`` whose private half no longer exists. USP defines no re-issuance
337
+ path for that, so the booking could never be cancelled from the agent
338
+ again.
339
+
340
+ Returns True if the key was removed.
341
+ """
342
+ slot = endpoint_key(endpoint)
343
+ if still_in_use or slot not in self._entries:
344
+ return False
345
+ del self._entries[slot]
346
+ self._save()
347
+ return True