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.
- booking_agent/__init__.py +6 -0
- booking_agent/authorization.py +450 -0
- booking_agent/backend/__init__.py +217 -0
- booking_agent/backend/live.py +494 -0
- booking_agent/calendar/__init__.py +55 -0
- booking_agent/calendar/filter.py +41 -0
- booking_agent/calendar/google.py +582 -0
- booking_agent/calendar/oauth_listener.py +287 -0
- booking_agent/calendar/scope.py +97 -0
- booking_agent/circuit_breaker.py +116 -0
- booking_agent/config.py +51 -0
- booking_agent/defaults.env +50 -0
- booking_agent/discovery.py +188 -0
- booking_agent/headless.py +463 -0
- booking_agent/http_deadlines.py +43 -0
- booking_agent/http_retry.py +178 -0
- booking_agent/http_trace.py +428 -0
- booking_agent/merchant_pacing.py +73 -0
- booking_agent/models/__init__.py +14 -0
- booking_agent/models/platform.py +200 -0
- booking_agent/models/wire.py +868 -0
- booking_agent/namespace.py +53 -0
- booking_agent/observability.py +143 -0
- booking_agent/platform_profile.py +120 -0
- booking_agent/pop.py +347 -0
- booking_agent/profile_cache.py +130 -0
- booking_agent/registry_url.py +35 -0
- booking_agent/rest_client.py +1493 -0
- booking_agent/secure_file.py +52 -0
- booking_agent/settings.py +110 -0
- booking_agent/signals.py +130 -0
- booking_agent/step_timing.py +168 -0
- booking_agent/ucp_capabilities.py +148 -0
- booking_agent/ucp_checkout.py +1363 -0
- booking_agent/ucp_negotiation.py +184 -0
- booking_agent/ucp_profile.py +410 -0
- booking_agent/usp_methods.py +110 -0
- booking_agent_cli/__init__.py +3 -0
- booking_agent_cli/activity_log.py +106 -0
- booking_agent_cli/booking_poll.py +141 -0
- booking_agent_cli/booking_summary.py +151 -0
- booking_agent_cli/calendar_connection.py +196 -0
- booking_agent_cli/calendar_event.py +611 -0
- booking_agent_cli/calendar_preference.py +176 -0
- booking_agent_cli/catalog.py +864 -0
- booking_agent_cli/checkout_prereq.py +67 -0
- booking_agent_cli/commands/__init__.py +0 -0
- booking_agent_cli/commands/config.py +106 -0
- booking_agent_cli/commands/flow.py +6988 -0
- booking_agent_cli/commands/lowlevel.py +163 -0
- booking_agent_cli/commands/runtime.py +46 -0
- booking_agent_cli/config_loader.py +108 -0
- booking_agent_cli/engine.py +168 -0
- booking_agent_cli/flow_timings.py +34 -0
- booking_agent_cli/geo_eligibility.py +183 -0
- booking_agent_cli/geocode.py +233 -0
- booking_agent_cli/identity.py +104 -0
- booking_agent_cli/link_cli.py +620 -0
- booking_agent_cli/main.py +75 -0
- booking_agent_cli/output.py +199 -0
- booking_agent_cli/payment_helpers.py +884 -0
- booking_agent_cli/payment_token.py +23 -0
- booking_agent_cli/reschedule.py +137 -0
- booking_agent_cli/runtime.py +55 -0
- booking_agent_cli/runtime_prepare.py +129 -0
- booking_agent_cli/search_area.py +111 -0
- booking_agent_cli/session.py +837 -0
- booking_agent_cli/support_bundle_schema.py +557 -0
- booking_agent_cli/support_report.py +1210 -0
- booking_agent_cli/timezone_context.py +314 -0
- booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
- booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
- booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- booking_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""``AuthorizationPolicy`` parsing and mechanism selection (USP Section 10.1.6).
|
|
2
|
+
|
|
3
|
+
USP splits operations into two tiers. *Public* operations -- catalog,
|
|
4
|
+
availability, profile discovery -- MAY remain anonymous. *Privileged*
|
|
5
|
+
operations -- creating, updating, confirming, cancelling or rescheduling a
|
|
6
|
+
booking, holds, waitlist mutations, payment-adjacent completion, registry
|
|
7
|
+
writes, and any response carrying buyer personal data -- MUST be authenticated.
|
|
8
|
+
A resource identifier is not a credential.
|
|
9
|
+
|
|
10
|
+
The *mechanism* is business-declared: the business publishes an
|
|
11
|
+
``AuthorizationPolicy`` and the platform selects one it supports. Two rules
|
|
12
|
+
make that safe, and both are easy to get backwards:
|
|
13
|
+
|
|
14
|
+
* Unrecognized mechanisms are **ignored, never treated as accepted**. A client
|
|
15
|
+
that shrugged and continued anonymously against a policy it did not
|
|
16
|
+
understand would defeat the whole tier split.
|
|
17
|
+
* Where the policy is missing or nothing is mutually supported, the client
|
|
18
|
+
**fails closed** rather than falling back to anonymous.
|
|
19
|
+
|
|
20
|
+
Placement differs by deployment mode:
|
|
21
|
+
|
|
22
|
+
* **Standalone** -- top-level ``authorization`` in ``/.well-known/usp``.
|
|
23
|
+
* **UCP-Native** -- ``config.authorization`` on the ``dev.usp-protocol.services``
|
|
24
|
+
binding. A binding carrying ``authorization`` as a *direct member* is
|
|
25
|
+
malformed: ``schemas/usp.json`` gives it a never-satisfiable schema so that it
|
|
26
|
+
is rejected rather than silently accepted, which would let a business believe
|
|
27
|
+
it had published a policy no conforming consumer reads. We therefore treat
|
|
28
|
+
that shape as *no policy published* and fail closed -- we do not read it.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import re
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from enum import Enum
|
|
36
|
+
from typing import Any, Dict, List, Optional
|
|
37
|
+
|
|
38
|
+
from pydantic import BaseModel, ConfigDict, field_validator
|
|
39
|
+
|
|
40
|
+
from . import settings
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
#: USP Section 10.1.6 mechanism identifiers this client can actually satisfy.
|
|
45
|
+
#: ``platform_key_pop`` is the only one implemented; the rest are recognized so
|
|
46
|
+
#: that diagnostics can say *why* a business is unreachable.
|
|
47
|
+
MECHANISM_PLATFORM_KEY_POP = "platform_key_pop"
|
|
48
|
+
MECHANISM_BOOKING_SCOPED_CREDENTIAL = "booking_scoped_credential"
|
|
49
|
+
MECHANISM_OAUTH2_BEARER = "oauth2_bearer"
|
|
50
|
+
MECHANISM_API_KEY = "api_key"
|
|
51
|
+
MECHANISM_HTTP_MESSAGE_SIGNATURE = "http_message_signature"
|
|
52
|
+
MECHANISM_MTLS = "mtls"
|
|
53
|
+
|
|
54
|
+
#: Ordered by preference. Only the first is implemented in this version;
|
|
55
|
+
#: ``oauth2_bearer`` support is dormant (see ``oauth_dormant``) and gated on #199.
|
|
56
|
+
SUPPORTED_MECHANISMS: tuple[str, ...] = (MECHANISM_PLATFORM_KEY_POP,)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AuthorizationError(Exception):
|
|
60
|
+
"""Base class for fail-closed authorization outcomes."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
#: USP Section 10.1.1 error codes relating to proofs. Note the ``pop_`` prefix: an
|
|
64
|
+
#: earlier draft of the client checklist called the replay code ``proof_replayed``,
|
|
65
|
+
#: which matches nothing on the wire.
|
|
66
|
+
ERR_POP_PROOF_MISSING = "pop_proof_missing"
|
|
67
|
+
ERR_POP_PROOF_INVALID = "pop_proof_invalid"
|
|
68
|
+
ERR_POP_KEY_MISMATCH = "pop_key_mismatch"
|
|
69
|
+
ERR_POP_PROOF_REPLAYED = "pop_proof_replayed"
|
|
70
|
+
ERR_PROOF_NONCE_REQUIRED = "proof_nonce_required"
|
|
71
|
+
|
|
72
|
+
#: All 401, and all terminal for the caller except the nonce challenge, which is
|
|
73
|
+
#: a single-round-trip negotiation rather than a failure.
|
|
74
|
+
POP_ERROR_CODES = frozenset({
|
|
75
|
+
ERR_POP_PROOF_MISSING,
|
|
76
|
+
ERR_POP_PROOF_INVALID,
|
|
77
|
+
ERR_POP_KEY_MISMATCH,
|
|
78
|
+
ERR_POP_PROOF_REPLAYED,
|
|
79
|
+
ERR_PROOF_NONCE_REQUIRED,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def nonce_from_pop_error_body(payload: Dict[str, Any]) -> Optional[str]:
|
|
84
|
+
"""Return a proof nonce from a USP error object (MCP and REST 401 fallback).
|
|
85
|
+
|
|
86
|
+
MCP has no HTTP headers, so the MCP binding carries the nonce in
|
|
87
|
+
``error.data.nonce`` when ``error.code`` is ``proof_nonce_required``. REST
|
|
88
|
+
prefers the ``DPoP-Nonce`` header; this parser is the fallback when the
|
|
89
|
+
header is absent on a 401.
|
|
90
|
+
"""
|
|
91
|
+
if payload.get("code") != ERR_PROOF_NONCE_REQUIRED:
|
|
92
|
+
return None
|
|
93
|
+
data = payload.get("data")
|
|
94
|
+
nonce = data.get("nonce") if isinstance(data, dict) else None
|
|
95
|
+
return nonce if isinstance(nonce, str) and nonce else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AuthorizationRefused(AuthorizationError):
|
|
99
|
+
"""The client refuses to perform a privileged operation against this business."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, code: str, message: str):
|
|
102
|
+
super().__init__(message)
|
|
103
|
+
self.code = code
|
|
104
|
+
self.message = message
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AuthorizationPolicy(BaseModel):
|
|
108
|
+
"""``schemas/profile.json#/$defs/AuthorizationPolicy``.
|
|
109
|
+
|
|
110
|
+
Both members are REQUIRED by the schema. The object is deliberately open
|
|
111
|
+
(``additionalProperties: true``) -- accepted proof algorithms and similar
|
|
112
|
+
extensions are advertised through it -- so unknown members are preserved
|
|
113
|
+
rather than dropped.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
model_config = ConfigDict(extra="allow")
|
|
117
|
+
|
|
118
|
+
privileged_operations_require_authentication: bool
|
|
119
|
+
accepted_mechanisms: List[str]
|
|
120
|
+
token_endpoint: Optional[str] = None
|
|
121
|
+
|
|
122
|
+
def select_mechanism(self) -> Optional[str]:
|
|
123
|
+
"""Return the best mutually supported mechanism, or None.
|
|
124
|
+
|
|
125
|
+
Unrecognized identifiers in ``accepted_mechanisms`` are skipped, never
|
|
126
|
+
assumed acceptable.
|
|
127
|
+
"""
|
|
128
|
+
for candidate in SUPPORTED_MECHANISMS:
|
|
129
|
+
if candidate in self.accepted_mechanisms:
|
|
130
|
+
return candidate
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_policy(raw: Any) -> Optional[AuthorizationPolicy]:
|
|
135
|
+
"""Parse an ``authorization`` object, or return None when absent/malformed.
|
|
136
|
+
|
|
137
|
+
A malformed policy is reported as *absent* rather than raising, so the
|
|
138
|
+
caller reaches the same fail-closed path as a business that published
|
|
139
|
+
nothing -- the distinction matters for the log line, not for the decision.
|
|
140
|
+
"""
|
|
141
|
+
if raw is None:
|
|
142
|
+
return None
|
|
143
|
+
if not isinstance(raw, dict):
|
|
144
|
+
logger.warning("Ignoring non-object authorization policy of type %s", type(raw).__name__)
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
return AuthorizationPolicy.model_validate(raw)
|
|
148
|
+
except Exception:
|
|
149
|
+
logger.warning("Ignoring malformed authorization policy: %r", raw, exc_info=True)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# The issued credential
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
class ConfirmationKey(BaseModel):
|
|
158
|
+
"""``schemas/usp.json#/$defs/ConfirmationKey`` -- the RFC 7800 ``cnf`` claim.
|
|
159
|
+
|
|
160
|
+
Restricted to the JWK-thumbprint form; the embedded-key and key-reference
|
|
161
|
+
forms are not used by USP. ``extra="forbid"`` is load-bearing: ignoring an
|
|
162
|
+
unrecognized confirmation method would silently downgrade a
|
|
163
|
+
sender-constrained credential to a bearer token, so the Section 10.1.6
|
|
164
|
+
forward-compatibility rule deliberately does NOT extend to this object.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
model_config = ConfigDict(extra="forbid")
|
|
168
|
+
|
|
169
|
+
jkt: str
|
|
170
|
+
|
|
171
|
+
@field_validator("jkt")
|
|
172
|
+
@classmethod
|
|
173
|
+
def _check_thumbprint(cls, v: str) -> str:
|
|
174
|
+
if not _JKT_RE.fullmatch(v):
|
|
175
|
+
raise ValueError(
|
|
176
|
+
"jkt must be an unpadded base64url SHA-256 thumbprint of exactly 43 characters"
|
|
177
|
+
)
|
|
178
|
+
return v
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class BookingScopedCredential(BaseModel):
|
|
182
|
+
"""``schemas/profile.json#/$defs/BookingScopedCredential``.
|
|
183
|
+
|
|
184
|
+
Returned on a creation response only, never on reads, and authorizing
|
|
185
|
+
continuation operations on exactly one booking or waitlist entry. Also a
|
|
186
|
+
**closed** object for the same reason as ``ConfirmationKey``.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
model_config = ConfigDict(extra="forbid")
|
|
190
|
+
|
|
191
|
+
value: str
|
|
192
|
+
expires_at: datetime
|
|
193
|
+
cnf: Optional[ConfirmationKey] = None
|
|
194
|
+
#: USP method names this credential authorizes on its resource. Absent means
|
|
195
|
+
#: the full continuation set for the resource type -- never another resource.
|
|
196
|
+
scope: Optional[List[str]] = None
|
|
197
|
+
|
|
198
|
+
@field_validator("value")
|
|
199
|
+
@classmethod
|
|
200
|
+
def _check_value(cls, v: str) -> str:
|
|
201
|
+
if not v.startswith("bsc_") or len(v) < 26:
|
|
202
|
+
raise ValueError("credential value must start with 'bsc_' and be at least 26 characters")
|
|
203
|
+
return v
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def is_sender_constrained(self) -> bool:
|
|
207
|
+
return self.cnf is not None
|
|
208
|
+
|
|
209
|
+
def is_expired(self, *, now: Optional[datetime] = None) -> bool:
|
|
210
|
+
current = now or datetime.now(timezone.utc)
|
|
211
|
+
expires = self.expires_at
|
|
212
|
+
if expires.tzinfo is None:
|
|
213
|
+
expires = expires.replace(tzinfo=timezone.utc)
|
|
214
|
+
return expires <= current
|
|
215
|
+
|
|
216
|
+
def covers(self, usp_method: str) -> bool:
|
|
217
|
+
return self.scope is None or usp_method in self.scope
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
_JKT_RE = re.compile(r"[A-Za-z0-9_-]{43}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class CredentialBindingError(AuthorizationError):
|
|
224
|
+
"""The business issued a credential that is not bound to our key."""
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def issued_credential_payload(data: Dict[str, Any]) -> Any:
|
|
228
|
+
"""Locate ``booking_scoped_credential`` on a creation response.
|
|
229
|
+
|
|
230
|
+
Standalone USP returns it at the body root. UCP-Native paid bookings return
|
|
231
|
+
it as a sibling of ``booking`` at the checkout root, or inside the
|
|
232
|
+
``dev.usp-protocol.services.paid_bookings`` extension. Creation is the only
|
|
233
|
+
place the value appears.
|
|
234
|
+
"""
|
|
235
|
+
from .namespace import CAP_PAID_BOOKINGS
|
|
236
|
+
|
|
237
|
+
if not isinstance(data, dict):
|
|
238
|
+
return None
|
|
239
|
+
payload = data.get("booking_scoped_credential")
|
|
240
|
+
if payload is not None:
|
|
241
|
+
return payload
|
|
242
|
+
extension = data.get(CAP_PAID_BOOKINGS)
|
|
243
|
+
if isinstance(extension, dict):
|
|
244
|
+
return extension.get("booking_scoped_credential")
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def parse_issued_credential(
|
|
249
|
+
payload: Any, *, own_jkt: str, required: bool,
|
|
250
|
+
) -> Optional[BookingScopedCredential]:
|
|
251
|
+
"""Parse and verify a ``booking_scoped_credential`` from a creation response.
|
|
252
|
+
|
|
253
|
+
Verifies ``cnf.jkt`` against *own_jkt*: this detects a business that accepted
|
|
254
|
+
the proof and then issued a credential bound to something else, or to
|
|
255
|
+
nothing. A credential returned without ``cnf`` MUST NOT be treated as
|
|
256
|
+
sender-constrained -- when we authenticated with ``platform_key_pop``
|
|
257
|
+
(*required*), that is a protocol violation rather than a soft downgrade,
|
|
258
|
+
because continuing would hand a plain bearer secret to a caller that
|
|
259
|
+
believes it holds a bound one.
|
|
260
|
+
"""
|
|
261
|
+
if payload is None:
|
|
262
|
+
return None
|
|
263
|
+
try:
|
|
264
|
+
credential = BookingScopedCredential.model_validate(payload)
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
raise CredentialBindingError(
|
|
267
|
+
f"business returned a malformed booking_scoped_credential: {exc}"
|
|
268
|
+
) from exc
|
|
269
|
+
|
|
270
|
+
if credential.cnf is None:
|
|
271
|
+
if required:
|
|
272
|
+
raise CredentialBindingError(
|
|
273
|
+
"business issued a booking_scoped_credential with no 'cnf' after a "
|
|
274
|
+
"platform_key_pop request; refusing to treat an unbound credential as "
|
|
275
|
+
"sender-constrained."
|
|
276
|
+
)
|
|
277
|
+
return credential
|
|
278
|
+
|
|
279
|
+
if credential.cnf.jkt != own_jkt:
|
|
280
|
+
raise CredentialBindingError(
|
|
281
|
+
f"business bound the credential to jkt {credential.cnf.jkt!r}, which is not "
|
|
282
|
+
f"this agent's key ({own_jkt!r}); the credential would be unusable."
|
|
283
|
+
)
|
|
284
|
+
return credential
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# Deployment posture
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
class ScopedCredentialPosture(str, Enum):
|
|
292
|
+
"""How strictly to enforce the Section 10.1.6 privileged-operation floor.
|
|
293
|
+
|
|
294
|
+
``REQUIRED`` is both the conformant posture and the default. USP is
|
|
295
|
+
unambiguous that privileged operations MUST be authenticated and that a
|
|
296
|
+
consumer fails closed rather than falling back to anonymous, so shipping a
|
|
297
|
+
laxer default would be shipping a known deviation.
|
|
298
|
+
|
|
299
|
+
Under ``REQUIRED``, privileged calls to a business that publishes no
|
|
300
|
+
``authorization`` policy, or declares privileged operations unauthenticated,
|
|
301
|
+
are refused: an unauthenticated booking is exactly what the mechanism exists
|
|
302
|
+
to prevent.
|
|
303
|
+
|
|
304
|
+
``PREFERRED`` is for a deployment that chooses to transact with such a
|
|
305
|
+
business anyway: the same cases log a warning and proceed instead of refusing.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
REQUIRED = "required"
|
|
309
|
+
PREFERRED = "preferred"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
DEFAULT_POSTURE = ScopedCredentialPosture.REQUIRED
|
|
313
|
+
|
|
314
|
+
#: Precedence: explicit config -> environment -> default.
|
|
315
|
+
POSTURE_ENV_VAR = "BOOKING_AGENT_USP_AUTH_SCOPED_CREDENTIAL"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def resolve_posture(explicit: Optional[str] = None) -> ScopedCredentialPosture:
|
|
319
|
+
raw = explicit or settings.get(POSTURE_ENV_VAR)
|
|
320
|
+
if not raw:
|
|
321
|
+
return DEFAULT_POSTURE
|
|
322
|
+
try:
|
|
323
|
+
return ScopedCredentialPosture(raw.strip().lower())
|
|
324
|
+
except ValueError:
|
|
325
|
+
logger.warning(
|
|
326
|
+
"Unknown %s=%r; falling back to %s",
|
|
327
|
+
POSTURE_ENV_VAR, raw, DEFAULT_POSTURE.value,
|
|
328
|
+
)
|
|
329
|
+
return DEFAULT_POSTURE
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
# Decision
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
class AuthorizationDecision(BaseModel):
|
|
337
|
+
"""What the client will do for privileged operations against one business."""
|
|
338
|
+
|
|
339
|
+
model_config = ConfigDict(frozen=True)
|
|
340
|
+
|
|
341
|
+
mechanism: Optional[str] = None
|
|
342
|
+
#: True when privileged requests should carry a proof.
|
|
343
|
+
authenticate: bool = False
|
|
344
|
+
#: Set when proceeding unauthenticated under a non-conformant posture.
|
|
345
|
+
warning: Optional[str] = None
|
|
346
|
+
#: Set when this business cannot be transacted with under the current
|
|
347
|
+
#: posture. Carried rather than raised so the refusal lands on the
|
|
348
|
+
#: privileged call that is actually forbidden -- see ``raise_if_refused``.
|
|
349
|
+
refusal_code: Optional[str] = None
|
|
350
|
+
refusal_message: Optional[str] = None
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def uses_pop(self) -> bool:
|
|
354
|
+
return self.mechanism == MECHANISM_PLATFORM_KEY_POP
|
|
355
|
+
|
|
356
|
+
@property
|
|
357
|
+
def refused(self) -> bool:
|
|
358
|
+
return self.refusal_code is not None
|
|
359
|
+
|
|
360
|
+
def raise_if_refused(self) -> None:
|
|
361
|
+
"""Enforce the refusal. Call this on privileged operations only.
|
|
362
|
+
|
|
363
|
+
Public operations -- catalog, availability, profile discovery -- MAY
|
|
364
|
+
remain unauthenticated under USP Section 10.1.6, so a business that publishes
|
|
365
|
+
no usable policy is still browsable. Refusing at client construction
|
|
366
|
+
would block those too, which is both non-conformant and needlessly
|
|
367
|
+
unhelpful: the agent could not even show the user what it cannot book.
|
|
368
|
+
"""
|
|
369
|
+
if self.refusal_code is not None:
|
|
370
|
+
raise AuthorizationRefused(self.refusal_code, self.refusal_message or "")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def evaluate(
|
|
374
|
+
policy: Optional[AuthorizationPolicy],
|
|
375
|
+
*,
|
|
376
|
+
posture: ScopedCredentialPosture,
|
|
377
|
+
exposes_privileged_capabilities: bool = True,
|
|
378
|
+
business_label: str = "business",
|
|
379
|
+
defer: bool = False,
|
|
380
|
+
) -> AuthorizationDecision:
|
|
381
|
+
"""Decide how to authenticate privileged operations against one business.
|
|
382
|
+
|
|
383
|
+
Raises ``AuthorizationRefused`` on every fail-closed path, unless *defer* is
|
|
384
|
+
set -- in which case the refusal is returned on the decision and enforced
|
|
385
|
+
later by ``AuthorizationDecision.raise_if_refused``. Callers that build a
|
|
386
|
+
client used for public operations too must defer, or browsing a business
|
|
387
|
+
would fail for want of an authorization policy it does not need.
|
|
388
|
+
"""
|
|
389
|
+
if policy is None:
|
|
390
|
+
if not exposes_privileged_capabilities:
|
|
391
|
+
return AuthorizationDecision(mechanism=None, authenticate=False)
|
|
392
|
+
return _refuse_or_warn(
|
|
393
|
+
posture,
|
|
394
|
+
defer=defer,
|
|
395
|
+
code="authorization_policy_absent",
|
|
396
|
+
message=(
|
|
397
|
+
f"{business_label} exposes privileged capabilities but publishes no "
|
|
398
|
+
f"authorization policy, so there is no mechanism to negotiate."
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if not policy.privileged_operations_require_authentication:
|
|
403
|
+
# Section 10.1.6: publishing false declares the deployment OUT OF CONFORMANCE
|
|
404
|
+
# for privileged operations. The platform MUST refuse to transact real
|
|
405
|
+
# bookings or transmit buyer personal data -- refuse, not warn and continue.
|
|
406
|
+
return _refuse_or_warn(
|
|
407
|
+
posture,
|
|
408
|
+
defer=defer,
|
|
409
|
+
code="privileged_operations_unauthenticated",
|
|
410
|
+
message=(
|
|
411
|
+
f"{business_label} publishes "
|
|
412
|
+
f"privileged_operations_require_authentication=false, declaring itself "
|
|
413
|
+
f"out of conformance for privileged operations. Refusing to transact "
|
|
414
|
+
f"real bookings or send buyer personal data."
|
|
415
|
+
),
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
mechanism = policy.select_mechanism()
|
|
419
|
+
if mechanism is None:
|
|
420
|
+
return _refuse_or_warn(
|
|
421
|
+
posture,
|
|
422
|
+
defer=defer,
|
|
423
|
+
code="no_supported_mechanism",
|
|
424
|
+
message=(
|
|
425
|
+
f"{business_label} accepts {policy.accepted_mechanisms!r}, none of which "
|
|
426
|
+
f"this client supports (supported: {list(SUPPORTED_MECHANISMS)!r})."
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return AuthorizationDecision(mechanism=mechanism, authenticate=True)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _refuse_or_warn(
|
|
434
|
+
posture: ScopedCredentialPosture, *, code: str, message: str, defer: bool = False,
|
|
435
|
+
) -> AuthorizationDecision:
|
|
436
|
+
if posture is ScopedCredentialPosture.REQUIRED:
|
|
437
|
+
if defer:
|
|
438
|
+
return AuthorizationDecision(
|
|
439
|
+
mechanism=None,
|
|
440
|
+
authenticate=False,
|
|
441
|
+
refusal_code=code,
|
|
442
|
+
refusal_message=message,
|
|
443
|
+
)
|
|
444
|
+
raise AuthorizationRefused(code, message)
|
|
445
|
+
logger.warning(
|
|
446
|
+
"%s Proceeding unauthenticated because %s=%s; this is not a conformant "
|
|
447
|
+
"configuration and privileged calls may be rejected.",
|
|
448
|
+
message, POSTURE_ENV_VAR, posture.value,
|
|
449
|
+
)
|
|
450
|
+
return AuthorizationDecision(mechanism=None, authenticate=False, warning=message)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""USP Backend abstraction layer.
|
|
2
|
+
|
|
3
|
+
Single interface (USPBackend) for all USP operations — registry search,
|
|
4
|
+
profile resolution, catalog, availability, and booking. The product
|
|
5
|
+
implementation is booking_agent.backend.live.LiveUSPBackend; tests inject
|
|
6
|
+
their own.
|
|
7
|
+
"""
|
|
8
|
+
import logging
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_iso(s: str) -> datetime:
|
|
14
|
+
"""Parse an ISO8601 datetime string, handling the Z suffix for Python 3.10."""
|
|
15
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
16
|
+
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from ..authorization import BookingScopedCredential
|
|
20
|
+
from ..discovery import USPBusinessProfile
|
|
21
|
+
from ..models.platform import ServiceSearchIntent
|
|
22
|
+
from ..models.wire import (
|
|
23
|
+
Booking,
|
|
24
|
+
Hold,
|
|
25
|
+
TimeSlot,
|
|
26
|
+
USPPaymentResult,
|
|
27
|
+
USPService,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class USPBackend(ABC):
|
|
34
|
+
"""Strategy interface for all USP operations."""
|
|
35
|
+
|
|
36
|
+
#: Set by the resource-creating operations (``hold_slot``, ``create_booking``)
|
|
37
|
+
#: to the ``booking_scoped_credential`` the business issued, already verified
|
|
38
|
+
#: against this agent's key. Read it immediately after the call: creation
|
|
39
|
+
#: responses are the only place a credential ever appears, and the next
|
|
40
|
+
#: creation overwrites it. ``None`` when the business issued none.
|
|
41
|
+
last_issued_credential: Optional[BookingScopedCredential] = None
|
|
42
|
+
|
|
43
|
+
# -- Registry-level (service discovery) ----------------------------------
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
async def search_registry(self, intent: ServiceSearchIntent) -> List[USPService]: ...
|
|
47
|
+
|
|
48
|
+
# -- Discovery (profile resolution) --------------------------------------
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
async def resolve_profile(self, usp_profile_url: str) -> USPBusinessProfile: ...
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
async def resolve_profile_from_agent_card(
|
|
55
|
+
self, agent_card_url: str
|
|
56
|
+
) -> USPBusinessProfile: ...
|
|
57
|
+
|
|
58
|
+
# -- Business-level (catalog) --------------------------------------------
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
async def list_services(
|
|
62
|
+
self,
|
|
63
|
+
profile: USPBusinessProfile,
|
|
64
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
65
|
+
) -> List[USPService]: ...
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
async def get_service(
|
|
69
|
+
self, profile: USPBusinessProfile, service_id: str
|
|
70
|
+
) -> USPService: ...
|
|
71
|
+
|
|
72
|
+
# -- Business-level (availability) ---------------------------------------
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
async def query_availability(
|
|
76
|
+
self,
|
|
77
|
+
profile: USPBusinessProfile,
|
|
78
|
+
service_id: str,
|
|
79
|
+
start_date: str,
|
|
80
|
+
end_date: str,
|
|
81
|
+
timezone: Optional[str] = None,
|
|
82
|
+
) -> List[TimeSlot]:
|
|
83
|
+
"""Slots for the window. ``timezone`` is sent as ``date_bounds_timezone``.
|
|
84
|
+
|
|
85
|
+
Live backends return an ``AvailabilityResult`` carrying the response's
|
|
86
|
+
``business_timezone`` and ``applied_date_bounds_timezone``.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
async def find_next_available(
|
|
90
|
+
self,
|
|
91
|
+
profile: USPBusinessProfile,
|
|
92
|
+
service_id: str,
|
|
93
|
+
after_date: str,
|
|
94
|
+
max_days: int = 30,
|
|
95
|
+
timezone_str: Optional[str] = None,
|
|
96
|
+
) -> Optional[str]:
|
|
97
|
+
try:
|
|
98
|
+
base = _parse_iso(after_date).date()
|
|
99
|
+
except (ValueError, TypeError):
|
|
100
|
+
base = datetime.now(timezone.utc).date()
|
|
101
|
+
start_day = base + timedelta(days=1)
|
|
102
|
+
end_day = base + timedelta(days=max_days)
|
|
103
|
+
try:
|
|
104
|
+
slots = await self.query_availability(
|
|
105
|
+
profile, service_id,
|
|
106
|
+
f"{start_day.isoformat()}T00:00:00Z",
|
|
107
|
+
f"{end_day.isoformat()}T23:59:59Z",
|
|
108
|
+
timezone_str,
|
|
109
|
+
)
|
|
110
|
+
except Exception:
|
|
111
|
+
logger.debug("find_next_available: query failed for %s", service_id)
|
|
112
|
+
return None
|
|
113
|
+
if not slots:
|
|
114
|
+
return None
|
|
115
|
+
earliest = min(slots, key=lambda s: s.start)
|
|
116
|
+
return _parse_iso(earliest.start).date().isoformat()
|
|
117
|
+
|
|
118
|
+
@abstractmethod
|
|
119
|
+
async def hold_slot(
|
|
120
|
+
self,
|
|
121
|
+
profile: USPBusinessProfile,
|
|
122
|
+
slot_id: str,
|
|
123
|
+
service_id: str,
|
|
124
|
+
spots: int = 1,
|
|
125
|
+
) -> Hold: ...
|
|
126
|
+
|
|
127
|
+
@abstractmethod
|
|
128
|
+
async def release_hold(
|
|
129
|
+
self,
|
|
130
|
+
profile: USPBusinessProfile,
|
|
131
|
+
hold_id: str,
|
|
132
|
+
*,
|
|
133
|
+
credential: Optional[BookingScopedCredential] = None,
|
|
134
|
+
) -> dict: ...
|
|
135
|
+
|
|
136
|
+
# -- Business-level (booking) --------------------------------------------
|
|
137
|
+
|
|
138
|
+
@abstractmethod
|
|
139
|
+
async def create_booking(
|
|
140
|
+
self,
|
|
141
|
+
profile: USPBusinessProfile,
|
|
142
|
+
service_id: str,
|
|
143
|
+
slot_id: str,
|
|
144
|
+
buyer: Dict[str, Any],
|
|
145
|
+
party_size: int = 1,
|
|
146
|
+
resource_id: Optional[str] = None,
|
|
147
|
+
notes: Optional[str] = None,
|
|
148
|
+
delivery_address: Optional[Dict[str, Any]] = None,
|
|
149
|
+
idempotency_key: Optional[str] = None,
|
|
150
|
+
) -> Booking: ...
|
|
151
|
+
|
|
152
|
+
@abstractmethod
|
|
153
|
+
async def get_booking(
|
|
154
|
+
self,
|
|
155
|
+
profile: USPBusinessProfile,
|
|
156
|
+
booking_id: str,
|
|
157
|
+
*,
|
|
158
|
+
credential: Optional[BookingScopedCredential] = None,
|
|
159
|
+
credential_endpoint: Optional[str] = None,
|
|
160
|
+
) -> Booking: ...
|
|
161
|
+
|
|
162
|
+
@abstractmethod
|
|
163
|
+
async def cancel_booking(
|
|
164
|
+
self,
|
|
165
|
+
profile: USPBusinessProfile,
|
|
166
|
+
booking_id: str,
|
|
167
|
+
reason: Optional[str] = None,
|
|
168
|
+
*,
|
|
169
|
+
credential: Optional[BookingScopedCredential] = None,
|
|
170
|
+
credential_endpoint: Optional[str] = None,
|
|
171
|
+
if_match: Optional[str] = None,
|
|
172
|
+
) -> Booking: ...
|
|
173
|
+
|
|
174
|
+
@abstractmethod
|
|
175
|
+
async def reschedule_booking(
|
|
176
|
+
self,
|
|
177
|
+
profile: USPBusinessProfile,
|
|
178
|
+
booking_id: str,
|
|
179
|
+
new_slot_id: str,
|
|
180
|
+
*,
|
|
181
|
+
credential: Optional[BookingScopedCredential] = None,
|
|
182
|
+
credential_endpoint: Optional[str] = None,
|
|
183
|
+
if_match: Optional[str] = None,
|
|
184
|
+
) -> Booking: ...
|
|
185
|
+
|
|
186
|
+
@abstractmethod
|
|
187
|
+
async def confirm_payment(
|
|
188
|
+
self,
|
|
189
|
+
profile: USPBusinessProfile,
|
|
190
|
+
booking_id: str,
|
|
191
|
+
payment_result: USPPaymentResult,
|
|
192
|
+
*,
|
|
193
|
+
credential: Optional[BookingScopedCredential] = None,
|
|
194
|
+
if_match: Optional[str] = None,
|
|
195
|
+
) -> Booking: ...
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_usp_backend() -> USPBackend:
|
|
199
|
+
from .live import LiveUSPBackend
|
|
200
|
+
return LiveUSPBackend()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ISO 4217 minor unit exponent. Default 2.
|
|
204
|
+
CURRENCY_MINOR_UNITS = {
|
|
205
|
+
"BIF": 0, "CLP": 0, "DJF": 0, "GNF": 0, "ISK": 0, "JPY": 0, "KMF": 0,
|
|
206
|
+
"KRW": 0, "PYG": 0, "RWF": 0, "UGX": 0, "UYI": 0, "VND": 0, "VUV": 0,
|
|
207
|
+
"XAF": 0, "XOF": 0, "XPF": 0,
|
|
208
|
+
"BHD": 3, "IQD": 3, "JOD": 3, "KWD": 3, "LYD": 3, "OMR": 3, "TND": 3,
|
|
209
|
+
"CLF": 4, "UYW": 4,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
CURRENCY_SYMBOLS = {"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "KRW": "₩"}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def minor_to_decimal(amount: int, currency: str) -> float:
|
|
216
|
+
exponent = CURRENCY_MINOR_UNITS.get(currency.upper(), 2)
|
|
217
|
+
return amount / (10 ** exponent)
|