booking-agent-cli 0.1.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.
- booking_agent_cli-0.1.0/.gitignore +28 -0
- booking_agent_cli-0.1.0/PKG-INFO +75 -0
- booking_agent_cli-0.1.0/README.md +35 -0
- booking_agent_cli-0.1.0/pyproject.toml +64 -0
- booking_agent_cli-0.1.0/src/booking_agent/__init__.py +6 -0
- booking_agent_cli-0.1.0/src/booking_agent/authorization.py +450 -0
- booking_agent_cli-0.1.0/src/booking_agent/backend/__init__.py +217 -0
- booking_agent_cli-0.1.0/src/booking_agent/backend/live.py +494 -0
- booking_agent_cli-0.1.0/src/booking_agent/calendar/__init__.py +55 -0
- booking_agent_cli-0.1.0/src/booking_agent/calendar/filter.py +41 -0
- booking_agent_cli-0.1.0/src/booking_agent/calendar/google.py +582 -0
- booking_agent_cli-0.1.0/src/booking_agent/calendar/oauth_listener.py +287 -0
- booking_agent_cli-0.1.0/src/booking_agent/calendar/scope.py +97 -0
- booking_agent_cli-0.1.0/src/booking_agent/circuit_breaker.py +116 -0
- booking_agent_cli-0.1.0/src/booking_agent/config.py +51 -0
- booking_agent_cli-0.1.0/src/booking_agent/defaults.env +50 -0
- booking_agent_cli-0.1.0/src/booking_agent/discovery.py +188 -0
- booking_agent_cli-0.1.0/src/booking_agent/headless.py +463 -0
- booking_agent_cli-0.1.0/src/booking_agent/http_deadlines.py +43 -0
- booking_agent_cli-0.1.0/src/booking_agent/http_retry.py +178 -0
- booking_agent_cli-0.1.0/src/booking_agent/http_trace.py +428 -0
- booking_agent_cli-0.1.0/src/booking_agent/merchant_pacing.py +73 -0
- booking_agent_cli-0.1.0/src/booking_agent/models/__init__.py +14 -0
- booking_agent_cli-0.1.0/src/booking_agent/models/platform.py +200 -0
- booking_agent_cli-0.1.0/src/booking_agent/models/wire.py +868 -0
- booking_agent_cli-0.1.0/src/booking_agent/namespace.py +53 -0
- booking_agent_cli-0.1.0/src/booking_agent/observability.py +143 -0
- booking_agent_cli-0.1.0/src/booking_agent/platform_profile.py +120 -0
- booking_agent_cli-0.1.0/src/booking_agent/pop.py +347 -0
- booking_agent_cli-0.1.0/src/booking_agent/profile_cache.py +130 -0
- booking_agent_cli-0.1.0/src/booking_agent/registry_url.py +35 -0
- booking_agent_cli-0.1.0/src/booking_agent/rest_client.py +1493 -0
- booking_agent_cli-0.1.0/src/booking_agent/secure_file.py +52 -0
- booking_agent_cli-0.1.0/src/booking_agent/settings.py +110 -0
- booking_agent_cli-0.1.0/src/booking_agent/signals.py +130 -0
- booking_agent_cli-0.1.0/src/booking_agent/step_timing.py +168 -0
- booking_agent_cli-0.1.0/src/booking_agent/ucp_capabilities.py +148 -0
- booking_agent_cli-0.1.0/src/booking_agent/ucp_checkout.py +1363 -0
- booking_agent_cli-0.1.0/src/booking_agent/ucp_negotiation.py +184 -0
- booking_agent_cli-0.1.0/src/booking_agent/ucp_profile.py +410 -0
- booking_agent_cli-0.1.0/src/booking_agent/usp_methods.py +110 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/__init__.py +3 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/activity_log.py +106 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/booking_poll.py +141 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/booking_summary.py +151 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/calendar_connection.py +196 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/calendar_event.py +611 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/calendar_preference.py +176 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/catalog.py +864 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/checkout_prereq.py +67 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/commands/__init__.py +0 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/commands/config.py +106 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/commands/flow.py +6988 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/commands/lowlevel.py +163 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/commands/runtime.py +46 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/config_loader.py +108 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/engine.py +168 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/flow_timings.py +34 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/geo_eligibility.py +183 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/geocode.py +233 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/identity.py +104 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/link_cli.py +620 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/main.py +75 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/output.py +199 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/payment_helpers.py +884 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/payment_token.py +23 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/reschedule.py +137 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/runtime.py +55 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/runtime_prepare.py +129 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/search_area.py +111 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/session.py +837 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/support_bundle_schema.py +557 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/support_report.py +1210 -0
- booking_agent_cli-0.1.0/src/booking_agent_cli/timezone_context.py +314 -0
- booking_agent_cli-0.1.0/tests/.env.tests +22 -0
- booking_agent_cli-0.1.0/tests/conftest.py +203 -0
- booking_agent_cli-0.1.0/tests/e2e/__init__.py +1 -0
- booking_agent_cli-0.1.0/tests/e2e/test_cancel_reschedule_e2e.py +324 -0
- booking_agent_cli-0.1.0/tests/e2e/test_latency_soak.py +53 -0
- booking_agent_cli-0.1.0/tests/e2e/test_staging_registry_integration.py +67 -0
- booking_agent_cli-0.1.0/tests/e2e/test_timezone_booking_e2e.py +153 -0
- booking_agent_cli-0.1.0/tests/e2e/test_ucp_native_e2e.py +88 -0
- booking_agent_cli-0.1.0/tests/e2e/test_ucp_skill_flow_e2e.py +329 -0
- booking_agent_cli-0.1.0/tests/e2e/ucp_native.py +273 -0
- booking_agent_cli-0.1.0/tests/integration/__init__.py +0 -0
- booking_agent_cli-0.1.0/tests/integration/test_activity_log.py +104 -0
- booking_agent_cli-0.1.0/tests/integration/test_at_rest_hygiene.py +151 -0
- booking_agent_cli-0.1.0/tests/integration/test_booking_rejection.py +154 -0
- booking_agent_cli-0.1.0/tests/integration/test_bulk_cancel.py +333 -0
- booking_agent_cli-0.1.0/tests/integration/test_calendar_connection.py +183 -0
- booking_agent_cli-0.1.0/tests/integration/test_calendar_connector.py +317 -0
- booking_agent_cli-0.1.0/tests/integration/test_calendar_flow.py +995 -0
- booking_agent_cli-0.1.0/tests/integration/test_calendar_preference.py +101 -0
- booking_agent_cli-0.1.0/tests/integration/test_checkout_signals.py +133 -0
- booking_agent_cli-0.1.0/tests/integration/test_circuit_breaker.py +91 -0
- booking_agent_cli-0.1.0/tests/integration/test_config.py +251 -0
- booking_agent_cli-0.1.0/tests/integration/test_correlation_flow.py +244 -0
- booking_agent_cli-0.1.0/tests/integration/test_declined_spt.py +782 -0
- booking_agent_cli-0.1.0/tests/integration/test_discover_registry_rejection.py +43 -0
- booking_agent_cli-0.1.0/tests/integration/test_flow_evidence.py +33 -0
- booking_agent_cli-0.1.0/tests/integration/test_flow_gates.py +3666 -0
- booking_agent_cli-0.1.0/tests/integration/test_flow_reschedule.py +572 -0
- booking_agent_cli-0.1.0/tests/integration/test_flow_step_timings.py +58 -0
- booking_agent_cli-0.1.0/tests/integration/test_google_calendar_oauth.py +171 -0
- booking_agent_cli-0.1.0/tests/integration/test_home_visit_address.py +144 -0
- booking_agent_cli-0.1.0/tests/integration/test_http_trace.py +156 -0
- booking_agent_cli-0.1.0/tests/integration/test_identity_logout.py +90 -0
- booking_agent_cli-0.1.0/tests/integration/test_lazy_oauth.py +158 -0
- booking_agent_cli-0.1.0/tests/integration/test_link_auth.py +320 -0
- booking_agent_cli-0.1.0/tests/integration/test_link_cli_spt.py +374 -0
- booking_agent_cli-0.1.0/tests/integration/test_namespace.py +135 -0
- booking_agent_cli-0.1.0/tests/integration/test_oauth_listener.py +131 -0
- booking_agent_cli-0.1.0/tests/integration/test_oauth_metadata.py +39 -0
- booking_agent_cli-0.1.0/tests/integration/test_platform_key_pop.py +605 -0
- booking_agent_cli-0.1.0/tests/integration/test_platform_profile.py +155 -0
- booking_agent_cli-0.1.0/tests/integration/test_pop_keystore.py +163 -0
- booking_agent_cli-0.1.0/tests/integration/test_profile_cache.py +50 -0
- booking_agent_cli-0.1.0/tests/integration/test_release_hold_uses_delete.py +88 -0
- booking_agent_cli-0.1.0/tests/integration/test_request_retry_semantics.py +147 -0
- booking_agent_cli-0.1.0/tests/integration/test_runtime_prepare.py +120 -0
- booking_agent_cli-0.1.0/tests/integration/test_search_area.py +154 -0
- booking_agent_cli-0.1.0/tests/integration/test_session_retention.py +334 -0
- booking_agent_cli-0.1.0/tests/integration/test_skill_portability.py +37 -0
- booking_agent_cli-0.1.0/tests/integration/test_support_report.py +1047 -0
- booking_agent_cli-0.1.0/tests/integration/test_timezone_context.py +325 -0
- booking_agent_cli-0.1.0/tests/integration/test_ucp_cancel_result.py +781 -0
- booking_agent_cli-0.1.0/tests/integration/test_ucp_checkout_pop.py +284 -0
- booking_agent_cli-0.1.0/tests/integration/test_ucp_checkout_proof_wireup.py +346 -0
- booking_agent_cli-0.1.0/tests/integration/test_ucp_checkout_validate.py +1076 -0
- booking_agent_cli-0.1.0/tests/integration/test_ucp_profile.py +260 -0
- booking_agent_cli-0.1.0/tests/integration/test_wheel_contents.py +40 -0
- booking_agent_cli-0.1.0/tests/spec_schemas/SOURCE +4 -0
- booking_agent_cli-0.1.0/tests/spec_schemas/acp_booking_extension.json +46 -0
- booking_agent_cli-0.1.0/tests/spec_schemas/availability.json +120 -0
- booking_agent_cli-0.1.0/tests/spec_schemas/booking.json +603 -0
- booking_agent_cli-0.1.0/tests/spec_schemas/calendar_freebusy.json +76 -0
- booking_agent_cli-0.1.0/tests/spec_schemas/catalog.json +615 -0
- booking_agent_cli-0.1.0/tests/spec_schemas/paid_bookings.json +201 -0
- booking_agent_cli-0.1.0/tests/spec_schemas/profile.json +189 -0
- booking_agent_cli-0.1.0/tests/spec_schemas/registry.json +448 -0
- booking_agent_cli-0.1.0/tests/spec_schemas/rest_common.json +91 -0
- booking_agent_cli-0.1.0/tests/spec_schemas/usp.json +407 -0
- booking_agent_cli-0.1.0/tests/spec_schemas/waitlist.json +132 -0
- booking_agent_cli-0.1.0/tests/spec_schemas/webhook_event.json +132 -0
- booking_agent_cli-0.1.0/tests/support/__init__.py +1 -0
- booking_agent_cli-0.1.0/tests/support/fixtures/__init__.py +5 -0
- booking_agent_cli-0.1.0/tests/support/fixtures/profiles.py +58 -0
- booking_agent_cli-0.1.0/tests/support/fixtures/services.py +226 -0
- booking_agent_cli-0.1.0/tests/support/mock/__init__.py +18 -0
- booking_agent_cli-0.1.0/tests/support/mock/backend.py +467 -0
- booking_agent_cli-0.1.0/tests/support/mock/link_cli.py +85 -0
- booking_agent_cli-0.1.0/tests/support/mock/mock_calendar.py +72 -0
- booking_agent_cli-0.1.0/tests/support/mock/pop_business.py +298 -0
- booking_agent_cli-0.1.0/tests/support/mock/pop_transport.py +279 -0
- booking_agent_cli-0.1.0/tests/support/mock/ucp_checkout_store.py +202 -0
- booking_agent_cli-0.1.0/tests/support/mock/ucp_server.py +228 -0
- booking_agent_cli-0.1.0/tests/support/offline_stack/__init__.py +1 -0
- booking_agent_cli-0.1.0/tests/support/offline_stack/stack.py +234 -0
- booking_agent_cli-0.1.0/tests/unit/__init__.py +0 -0
- booking_agent_cli-0.1.0/tests/unit/test_authorization_policy.py +207 -0
- booking_agent_cli-0.1.0/tests/unit/test_booking_poll_credentials.py +69 -0
- booking_agent_cli-0.1.0/tests/unit/test_booking_request_error.py +115 -0
- booking_agent_cli-0.1.0/tests/unit/test_calendar_filter.py +47 -0
- booking_agent_cli-0.1.0/tests/unit/test_catalog.py +577 -0
- booking_agent_cli-0.1.0/tests/unit/test_conditional_writes.py +136 -0
- booking_agent_cli-0.1.0/tests/unit/test_delivery_address.py +125 -0
- booking_agent_cli-0.1.0/tests/unit/test_discovery.py +31 -0
- booking_agent_cli-0.1.0/tests/unit/test_geo_eligibility.py +63 -0
- booking_agent_cli-0.1.0/tests/unit/test_geocode.py +105 -0
- booking_agent_cli-0.1.0/tests/unit/test_headless.py +114 -0
- booking_agent_cli-0.1.0/tests/unit/test_http_deadlines.py +41 -0
- booking_agent_cli-0.1.0/tests/unit/test_http_retry.py +146 -0
- booking_agent_cli-0.1.0/tests/unit/test_live_search_registry.py +474 -0
- booking_agent_cli-0.1.0/tests/unit/test_merchant_pacing.py +99 -0
- booking_agent_cli-0.1.0/tests/unit/test_mock_support.py +55 -0
- booking_agent_cli-0.1.0/tests/unit/test_money_path_events.py +243 -0
- booking_agent_cli-0.1.0/tests/unit/test_nonce_from_pop_error_body.py +31 -0
- booking_agent_cli-0.1.0/tests/unit/test_observability.py +70 -0
- booking_agent_cli-0.1.0/tests/unit/test_outbound_member_names.py +121 -0
- booking_agent_cli-0.1.0/tests/unit/test_package_layering.py +19 -0
- booking_agent_cli-0.1.0/tests/unit/test_registry_search_filters.py +175 -0
- booking_agent_cli-0.1.0/tests/unit/test_registry_search_pricing.py +97 -0
- booking_agent_cli-0.1.0/tests/unit/test_registry_search_responses.py +246 -0
- booking_agent_cli-0.1.0/tests/unit/test_registry_url.py +29 -0
- booking_agent_cli-0.1.0/tests/unit/test_rest_client.py +233 -0
- booking_agent_cli-0.1.0/tests/unit/test_scoped_method_authorization.py +137 -0
- booking_agent_cli-0.1.0/tests/unit/test_session_credentials.py +186 -0
- booking_agent_cli-0.1.0/tests/unit/test_spec_conformance.py +114 -0
- booking_agent_cli-0.1.0/tests/unit/test_step_timing.py +92 -0
- booking_agent_cli-0.1.0/tests/unit/test_ucp_capabilities.py +169 -0
- booking_agent_cli-0.1.0/tests/unit/test_ucp_complete_outcome.py +159 -0
- booking_agent_cli-0.1.0/tests/unit/test_ucp_create_price_guard.py +116 -0
- booking_agent_cli-0.1.0/tests/unit/test_ucp_negotiation.py +169 -0
- booking_agent_cli-0.1.0/tests/unit/test_wire_model_conformance.py +317 -0
- booking_agent_cli-0.1.0/tests/vectors/pop/001-jcs-nested-buyer.json +30 -0
- booking_agent_cli-0.1.0/tests/vectors/pop/002-jcs-meta-only.json +22 -0
- booking_agent_cli-0.1.0/tests/vectors/pop/003-jcs-unicode-empty-meta.json +25 -0
- booking_agent_cli-0.1.0/tests/vectors/pop/004-proof-issuance-no-ath.json +27 -0
- booking_agent_cli-0.1.0/tests/vectors/pop/005-proof-presentation-ath-nonce.json +36 -0
- booking_agent_cli-0.1.0/tests/vectors/pop/006-cross-key-replay.json +39 -0
- booking_agent_cli-0.1.0/tests/vectors/pop/007-downgrade-cnf-without-proof.json +22 -0
- booking_agent_cli-0.1.0/tests/vectors/pop/008-replayed-jti.json +40 -0
- booking_agent_cli-0.1.0/tests/vectors/pop/009-aud-mismatch.json +38 -0
- booking_agent_cli-0.1.0/tests/vectors/pop/010-alg-none.json +31 -0
- booking_agent_cli-0.1.0/tests/vectors/pop/README.md +66 -0
- booking_agent_cli-0.1.0/tests/vectors/pop/SOURCE +4 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
.pytest_cache/
|
|
4
|
+
.ruff_cache/
|
|
5
|
+
.mypy_cache/
|
|
6
|
+
.venv/
|
|
7
|
+
.DS_Store
|
|
8
|
+
|
|
9
|
+
# Build outputs
|
|
10
|
+
build/
|
|
11
|
+
dist/
|
|
12
|
+
*.egg-info/
|
|
13
|
+
*.whl
|
|
14
|
+
|
|
15
|
+
# Local secrets and runtime state (never commit)
|
|
16
|
+
.env
|
|
17
|
+
.env.*
|
|
18
|
+
!.env.tests
|
|
19
|
+
.usp-session.json
|
|
20
|
+
.google-calendar-tokens/
|
|
21
|
+
usp_tokens.json
|
|
22
|
+
http_trace.log
|
|
23
|
+
|
|
24
|
+
# IDE: keep only the shared JetBrains ignore file
|
|
25
|
+
.idea/*
|
|
26
|
+
!.idea/.gitignore
|
|
27
|
+
.vscode/
|
|
28
|
+
*.iml
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: booking-agent-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The Booking Agent CLI: a stateful USP booking flow with hard gates, plus its USP protocol library.
|
|
5
|
+
Project-URL: Homepage, https://github.com/wix/booking-agent
|
|
6
|
+
Project-URL: Documentation, https://github.com/wix/booking-agent/blob/master/booking-agent/README.md
|
|
7
|
+
Project-URL: Source, https://github.com/wix/booking-agent/tree/master/booking-agent/packages/cli
|
|
8
|
+
Project-URL: Issues, https://github.com/wix/booking-agent/issues
|
|
9
|
+
Author: Wix.com
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Keywords: agent,booking,cli,scheduling,ucp,usp
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Office/Business :: Scheduling
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: click>=8.2
|
|
26
|
+
Requires-Dist: cryptography>=42.0
|
|
27
|
+
Requires-Dist: httpx>=0.27
|
|
28
|
+
Requires-Dist: pydantic>=2.6
|
|
29
|
+
Requires-Dist: python-dotenv>=1.0
|
|
30
|
+
Requires-Dist: rich>=13.7
|
|
31
|
+
Requires-Dist: starlette>=0.37
|
|
32
|
+
Requires-Dist: timezonefinder<7,>=6.5
|
|
33
|
+
Requires-Dist: typer>=0.16
|
|
34
|
+
Requires-Dist: uvicorn>=0.29
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: jsonschema>=4; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest-asyncio>=0.23.5; extra == 'dev'
|
|
38
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# booking-agent-cli
|
|
42
|
+
|
|
43
|
+
The `booking-agent` command books time-based services (haircuts, lessons,
|
|
44
|
+
repairs, classes, appointments) through the Universal Scheduling Protocol (USP),
|
|
45
|
+
paying from a Link wallet. It owns the booking state machine and refuses to
|
|
46
|
+
proceed when a human approval gate is skipped, so an AI agent can drive it safely.
|
|
47
|
+
|
|
48
|
+
The wheel ships two import packages:
|
|
49
|
+
|
|
50
|
+
- `booking_agent_cli` - the `booking-agent` command
|
|
51
|
+
- `booking_agent` - the USP/UCP protocol library (models, discovery, REST client)
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
Requires Python 3.10 or newer.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv tool install booking-agent-cli
|
|
59
|
+
# or
|
|
60
|
+
pipx install booking-agent-cli
|
|
61
|
+
|
|
62
|
+
booking-agent --help
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Agents use the CLI through the Booking Agent skill:
|
|
66
|
+
https://raw.githubusercontent.com/wix/booking-agent/master/booking-agent/skills/booking-agent/SKILL.md
|
|
67
|
+
|
|
68
|
+
The skill also needs `link-cli` for buyer identity and payment approval; see the
|
|
69
|
+
skill for details.
|
|
70
|
+
|
|
71
|
+
## Source and license
|
|
72
|
+
|
|
73
|
+
Source: https://github.com/wix/booking-agent (directory `booking-agent/`).
|
|
74
|
+
Licensed under the Apache License 2.0; see
|
|
75
|
+
https://github.com/wix/booking-agent/blob/master/LICENSE.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# booking-agent-cli
|
|
2
|
+
|
|
3
|
+
The `booking-agent` command books time-based services (haircuts, lessons,
|
|
4
|
+
repairs, classes, appointments) through the Universal Scheduling Protocol (USP),
|
|
5
|
+
paying from a Link wallet. It owns the booking state machine and refuses to
|
|
6
|
+
proceed when a human approval gate is skipped, so an AI agent can drive it safely.
|
|
7
|
+
|
|
8
|
+
The wheel ships two import packages:
|
|
9
|
+
|
|
10
|
+
- `booking_agent_cli` - the `booking-agent` command
|
|
11
|
+
- `booking_agent` - the USP/UCP protocol library (models, discovery, REST client)
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Requires Python 3.10 or newer.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv tool install booking-agent-cli
|
|
19
|
+
# or
|
|
20
|
+
pipx install booking-agent-cli
|
|
21
|
+
|
|
22
|
+
booking-agent --help
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Agents use the CLI through the Booking Agent skill:
|
|
26
|
+
https://raw.githubusercontent.com/wix/booking-agent/master/booking-agent/skills/booking-agent/SKILL.md
|
|
27
|
+
|
|
28
|
+
The skill also needs `link-cli` for buyer identity and payment approval; see the
|
|
29
|
+
skill for details.
|
|
30
|
+
|
|
31
|
+
## Source and license
|
|
32
|
+
|
|
33
|
+
Source: https://github.com/wix/booking-agent (directory `booking-agent/`).
|
|
34
|
+
Licensed under the Apache License 2.0; see
|
|
35
|
+
https://github.com/wix/booking-agent/blob/master/LICENSE.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "booking-agent-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "The Booking Agent CLI: a stateful USP booking flow with hard gates, plus its USP protocol library."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
authors = [{ name = "Wix.com" }]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
keywords = ["booking", "scheduling", "usp", "ucp", "agent", "cli"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Topic :: Office/Business :: Scheduling",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
# typer's runtime; the tests only pass on click>=8.2 (separate stderr capture).
|
|
26
|
+
"click>=8.2",
|
|
27
|
+
# Ed25519 for platform_key_pop proof-of-possession (USP Section 10.1.6).
|
|
28
|
+
"cryptography>=42.0",
|
|
29
|
+
"httpx>=0.27",
|
|
30
|
+
"pydantic>=2.6",
|
|
31
|
+
"python-dotenv>=1.0",
|
|
32
|
+
"rich>=13.7",
|
|
33
|
+
"starlette>=0.37",
|
|
34
|
+
"timezonefinder>=6.5,<7",
|
|
35
|
+
# Older typer releases either cap click below 8.2 or break on current click.
|
|
36
|
+
"typer>=0.16",
|
|
37
|
+
"uvicorn>=0.29",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
# pytest-asyncio 0.23.0-0.23.4 crash on pytest 8 collection.
|
|
42
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23.5", "jsonschema>=4"]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/wix/booking-agent"
|
|
46
|
+
Documentation = "https://github.com/wix/booking-agent/blob/master/booking-agent/README.md"
|
|
47
|
+
Source = "https://github.com/wix/booking-agent/tree/master/booking-agent/packages/cli"
|
|
48
|
+
Issues = "https://github.com/wix/booking-agent/issues"
|
|
49
|
+
|
|
50
|
+
[project.scripts]
|
|
51
|
+
booking-agent = "booking_agent_cli.main:run"
|
|
52
|
+
|
|
53
|
+
[build-system]
|
|
54
|
+
# 1.27 is the first release that writes SPDX `license` expressions (PEP 639).
|
|
55
|
+
requires = ["hatchling>=1.27"]
|
|
56
|
+
build-backend = "hatchling.build"
|
|
57
|
+
|
|
58
|
+
# 2.4 is the newest core metadata every upload path accepts; recent hatchling defaults to 2.5.
|
|
59
|
+
[tool.hatch.build.targets.sdist]
|
|
60
|
+
core-metadata-version = "2.4"
|
|
61
|
+
|
|
62
|
+
[tool.hatch.build.targets.wheel]
|
|
63
|
+
packages = ["src/booking_agent", "src/booking_agent_cli"]
|
|
64
|
+
core-metadata-version = "2.4"
|
|
@@ -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)
|