qurl-python 0.2.1__py3-none-any.whl → 0.3.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.
layerv_qurl/__init__.py CHANGED
@@ -1,10 +1,16 @@
1
- """qURL Python SDK — secure, time-limited access links for AI agents."""
1
+ """qURL Python SDK — protect a private URL once, then mint short-lived portal links for it.
2
+
3
+ A portal is cryptographic, just-in-time permission for one actor to reach
4
+ one private resource. Core flow: :meth:`QURLClient.protect_url` →
5
+ :meth:`ProtectedResource.create_portal`; programmatic openers use
6
+ :meth:`QURLClient.enter_portal`.
7
+ """
2
8
 
3
9
  from importlib.metadata import PackageNotFoundError
4
10
  from importlib.metadata import version as _pkg_version
5
11
 
6
- from layerv_qurl.async_client import AsyncQURLClient
7
- from layerv_qurl.client import QURLClient
12
+ from layerv_qurl.async_client import AsyncProtectedResource, AsyncQURLClient
13
+ from layerv_qurl.client import ProtectedResource, QURLClient
8
14
  from layerv_qurl.errors import (
9
15
  AuthenticationError,
10
16
  AuthorizationError,
@@ -25,7 +31,6 @@ from layerv_qurl.types import (
25
31
  AccessGrant,
26
32
  AccessPolicy,
27
33
  AccessToken,
28
- AgentBootstrapOutput,
29
34
  AIAgentPolicy,
30
35
  APIKey,
31
36
  APIKeyListOutput,
@@ -51,7 +56,7 @@ from layerv_qurl.types import (
51
56
  InvoiceListOutput,
52
57
  ListOutput,
53
58
  MintOutput,
54
- NHPServerPeerInfo,
59
+ Portal,
55
60
  PortalSession,
56
61
  Quota,
57
62
  QuotaPlan,
@@ -61,6 +66,7 @@ from layerv_qurl.types import (
61
66
  ResolveOutput,
62
67
  Resource,
63
68
  ResourceDetail,
69
+ ResourceHandle,
64
70
  ResourceListOutput,
65
71
  ResourceType,
66
72
  Session,
@@ -80,7 +86,9 @@ from layerv_qurl.types import (
80
86
  )
81
87
 
82
88
  __all__ = [
89
+ "AsyncProtectedResource",
83
90
  "AsyncQURLClient",
91
+ "ProtectedResource",
84
92
  "QURLClient",
85
93
  # Errors
86
94
  "AuthenticationError",
@@ -98,7 +106,6 @@ __all__ = [
98
106
  "AccessCode",
99
107
  "AccessCodeListOutput",
100
108
  "AIAgentPolicy",
101
- "AgentBootstrapOutput",
102
109
  "APIKey",
103
110
  "APIKeyListOutput",
104
111
  "BatchCreateItem",
@@ -120,13 +127,14 @@ __all__ = [
120
127
  "DomainVerifyOutput",
121
128
  "Invoice",
122
129
  "InvoiceListOutput",
123
- "NHPServerPeerInfo",
130
+ "Portal",
124
131
  "PortalSession",
125
132
  "QuotaPlan",
126
133
  "QURLStatus",
127
134
  "RedeemAccessCodeOutput",
128
135
  "Resource",
129
136
  "ResourceDetail",
137
+ "ResourceHandle",
130
138
  "ResourceListOutput",
131
139
  "ResourceType",
132
140
  "Session",
layerv_qurl/_utils.py CHANGED
@@ -19,11 +19,11 @@ import secrets
19
19
  import time
20
20
  import uuid
21
21
  from collections.abc import Iterable, Mapping, Set
22
- from datetime import datetime
22
+ from datetime import datetime, timedelta
23
23
  from importlib.metadata import PackageNotFoundError
24
24
  from importlib.metadata import version as _pkg_version
25
25
  from typing import TYPE_CHECKING, Any, TypeVar
26
- from urllib.parse import quote
26
+ from urllib.parse import quote, urlsplit
27
27
 
28
28
  from layerv_qurl.errors import (
29
29
  AuthenticationError,
@@ -43,7 +43,6 @@ from layerv_qurl.types import (
43
43
  AccessGrant,
44
44
  AccessPolicy,
45
45
  AccessToken,
46
- AgentBootstrapOutput,
47
46
  AIAgentPolicy,
48
47
  APIKey,
49
48
  APIKeyListOutput,
@@ -68,7 +67,7 @@ from layerv_qurl.types import (
68
67
  InvoiceListOutput,
69
68
  ListOutput,
70
69
  MintOutput,
71
- NHPServerPeerInfo,
70
+ Portal,
72
71
  PortalSession,
73
72
  Quota,
74
73
  RateLimits,
@@ -76,6 +75,7 @@ from layerv_qurl.types import (
76
75
  ResolveOutput,
77
76
  Resource,
78
77
  ResourceDetail,
78
+ ResourceHandle,
79
79
  ResourceListOutput,
80
80
  Session,
81
81
  SessionListOutput,
@@ -659,6 +659,310 @@ def parse_resolve_output(data: dict[str, Any]) -> ResolveOutput:
659
659
  )
660
660
 
661
661
 
662
+ # ---- Portal verbs (mirrors qurl-go) --------------------------------------
663
+ # Shared by client.py and async_client.py so the portal surface stays in
664
+ # lockstep between the sync and async clients and with qurl-go's
665
+ # client-side option guardrails.
666
+
667
+ #: Client-side floor for portal lifetimes, mirroring qurl-go's ``ValidFor``.
668
+ #: The LayerV API remains the source of truth for account limits; there is
669
+ #: intentionally no SDK-side maximum.
670
+ MIN_PORTAL_VALID_FOR_SECONDS = 60
671
+ #: Client-side floor for session durations, mirroring qurl-go's
672
+ #: ``WithSessionDuration`` (at least one second; zero is rejected rather
673
+ #: than treated as the server default).
674
+ MIN_SESSION_DURATION_SECONDS = 1
675
+
676
+
677
+ def serialize_api_duration(
678
+ value: str | timedelta | None, *, field_name: str, minimum_seconds: int
679
+ ) -> str | None:
680
+ """Serialize a duration option to the API's h/m/s duration grammar.
681
+
682
+ Strings pass through unvalidated — the server is authoritative, same
683
+ as the REST-shaped ``expires_in`` fields. :class:`~datetime.timedelta`
684
+ values get qurl-go's client-side guardrails: whole seconds only and at
685
+ least ``minimum_seconds``. Serialized with hours as the largest unit
686
+ so all API duration fields use the same grammar across SDKs.
687
+ """
688
+ if value is None:
689
+ return None
690
+ if isinstance(value, str):
691
+ if not value.strip():
692
+ raise ValueError(f"{field_name}: must be a non-empty duration string")
693
+ return value
694
+ if isinstance(value, timedelta):
695
+ total = value.total_seconds()
696
+ # Minimum before whole-seconds, matching qurl-go's check order so
697
+ # e.g. 30.5s reports "at least 60s" in both SDKs.
698
+ if total < minimum_seconds:
699
+ raise ValueError(f"{field_name}: must be at least {minimum_seconds}s")
700
+ seconds = int(total)
701
+ if seconds != total:
702
+ raise ValueError(f"{field_name}: must be whole seconds")
703
+ if seconds % 3600 == 0:
704
+ return f"{seconds // 3600}h"
705
+ if seconds % 60 == 0:
706
+ return f"{seconds // 60}m"
707
+ return f"{seconds}s"
708
+ raise ValueError(
709
+ f"{field_name}: must be a duration string (e.g. '5m') or datetime.timedelta"
710
+ )
711
+
712
+
713
+ def validate_portal_target_url(target_url: str) -> None:
714
+ """Reject malformed target URLs and embedded credentials (qurl-go parity).
715
+
716
+ The portal verbs mirror qurl-go's ``ProtectURL`` guardrails: http(s)
717
+ scheme, a host, and no userinfo. The REST-shaped compatibility layer
718
+ keeps its original prefix-only check so its accepted inputs don't
719
+ change.
720
+
721
+ A userinfo URL contains credentials that must stay out of logs and
722
+ tracebacks, so the userinfo check runs FIRST with a non-echoing error
723
+ — before ``_require_http_url``, which echoes its input. Otherwise a
724
+ credentialed URL with a bad scheme (``ftp://user:pass@host``) would
725
+ leak the password through the scheme error before ever reaching the
726
+ userinfo branch.
727
+ """
728
+ if not isinstance(target_url, str) or not target_url.strip():
729
+ raise ValueError("target_url: must be a non-empty string")
730
+ try:
731
+ parts = urlsplit(target_url)
732
+ # Accessed inside the try: .hostname can also raise ValueError
733
+ # for malformed netlocs (e.g. unbalanced IPv6 brackets).
734
+ username, password, hostname = parts.username, parts.password, parts.hostname
735
+ except ValueError:
736
+ raise ValueError("target_url: malformed URL") from None
737
+ if username is not None or password is not None:
738
+ # Reject credentials before any echoing validator sees the URL.
739
+ raise ValueError(
740
+ "target_url: must not include embedded credentials (userinfo)"
741
+ )
742
+ # Safe to echo now: the URL carries no userinfo. This enforces the
743
+ # http(s) scheme and length and gives a helpful (input-echoing) error
744
+ # for credential-free malformed URLs.
745
+ _require_http_url(target_url, "target_url")
746
+ if not hostname:
747
+ raise ValueError("target_url: must include a host")
748
+
749
+
750
+ def build_protect_body(
751
+ *,
752
+ target_url: str,
753
+ description: str | None,
754
+ tags: list[str] | None,
755
+ custom_domain: str | None,
756
+ alias: str | None,
757
+ ) -> dict[str, Any]:
758
+ """Validate protect-url inputs and build the create-resource request body."""
759
+ validate_portal_target_url(target_url)
760
+ validate_update_input(
761
+ description=description, tags=tags, custom_domain=custom_domain
762
+ )
763
+ validate_alias(alias)
764
+ return build_body(
765
+ {
766
+ "target_url": target_url,
767
+ "description": description,
768
+ "tags": tags,
769
+ "custom_domain": custom_domain,
770
+ "alias": alias,
771
+ }
772
+ )
773
+
774
+
775
+ def require_connector_resource(
776
+ resources: list[Resource], connector_id: str
777
+ ) -> Resource:
778
+ """Select the single alias-matching resource from a connector lookup.
779
+
780
+ Mirrors qurl-go's ``ConnectorResource`` checks: the slug lookup must
781
+ return exactly one resource, and its alias must equal the connector
782
+ id. Raises the portal surface's client-detected errors (``status=0``)
783
+ otherwise — see :meth:`QURLClient.connector_resource` for the mapping.
784
+
785
+ ASSUMPTION: ``resources`` is the complete result set for the slug —
786
+ ``slug`` is an exact, unique server-side filter, so a connector maps
787
+ to at most one resource. This is the same contract qurl-go relies on;
788
+ the caller passes a single ``list_resources(slug=...)`` page and does
789
+ not paginate. If ``slug`` ever became a non-exact (prefix/substring)
790
+ filter whose matches could span pages, the ``len > 1`` ambiguity guard
791
+ below could be evaded and this lookup would need to page through the
792
+ full set.
793
+ """
794
+ if not resources:
795
+ raise NotFoundError(
796
+ status=0,
797
+ code="resource_not_found",
798
+ title="Resource Not Found",
799
+ detail=f"connector_resource: no resource found for connector {connector_id!r}",
800
+ )
801
+ if len(resources) > 1:
802
+ raise QURLError(
803
+ status=0,
804
+ code="ambiguous_resource",
805
+ title="Ambiguous Resource",
806
+ detail=(
807
+ f"connector_resource: connector {connector_id!r} returned "
808
+ f"{len(resources)} resources"
809
+ ),
810
+ )
811
+ resource = resources[0]
812
+ if resource.alias != connector_id:
813
+ raise ValidationError(
814
+ status=0,
815
+ code="unexpected_response",
816
+ title="Unexpected Response",
817
+ detail=(
818
+ f"connector_resource: connector {connector_id!r} returned a "
819
+ "resource with a missing or different alias"
820
+ ),
821
+ )
822
+ return resource
823
+
824
+
825
+ def build_portal_body(
826
+ *,
827
+ valid_for: str | timedelta | None,
828
+ label: str | None,
829
+ one_time_use: bool | None,
830
+ max_sessions: int | None,
831
+ session_duration: str | timedelta | None,
832
+ ) -> dict[str, Any]:
833
+ """Validate portal options and build a create-portal request body."""
834
+ # qurl-go's WithLabel rejects empty labels; the REST-shaped mint_link
835
+ # keeps its permissive behavior, so the check lives here rather than
836
+ # in validate_mint_input.
837
+ if label is not None and not label.strip():
838
+ raise ValueError("label: must not be empty")
839
+ validate_mint_input(label=label, max_sessions=max_sessions)
840
+ return build_body(
841
+ {
842
+ "expires_in": serialize_api_duration(
843
+ valid_for,
844
+ field_name="valid_for",
845
+ minimum_seconds=MIN_PORTAL_VALID_FOR_SECONDS,
846
+ ),
847
+ "label": label,
848
+ "one_time_use": one_time_use,
849
+ "max_sessions": max_sessions,
850
+ "session_duration": serialize_api_duration(
851
+ session_duration,
852
+ field_name="session_duration",
853
+ minimum_seconds=MIN_SESSION_DURATION_SECONDS,
854
+ ),
855
+ }
856
+ )
857
+
858
+
859
+ def parse_portal(data: dict[str, Any]) -> Portal:
860
+ """Parse a Portal from a create-portal API response.
861
+
862
+ Fails closed like qurl-go when the response lacks its identity
863
+ fields — a portal without a resource id or link is not actionable.
864
+ Everything else is optional because the two mint endpoints differ in
865
+ which ancillary fields they return.
866
+ """
867
+ resource_id = data.get("resource_id")
868
+ qurl_link = data.get("qurl_link")
869
+ if not resource_id or not qurl_link:
870
+ raise ValidationError(
871
+ status=0,
872
+ code="unexpected_response",
873
+ title="Unexpected Response",
874
+ detail="create portal: API response is missing resource_id or qurl_link",
875
+ )
876
+ return Portal(
877
+ resource_id=resource_id,
878
+ link=qurl_link,
879
+ site=data.get("qurl_site"),
880
+ expires_at=_parse_dt(data.get("expires_at")),
881
+ # Same empty-string → None normalization as parse_create_output.
882
+ qurl_id=data.get("qurl_id") or None,
883
+ label=data.get("label"),
884
+ )
885
+
886
+
887
+ # qurl-go's offline signed-fragment links have the four-part shape
888
+ # <version>.<claims>.<secret>.<sig>. We match any fragment of 3+
889
+ # dot-separated base64url parts — broad enough to catch malformed or
890
+ # future-versioned signed links — only to give them a precise error below
891
+ # (a bare access token never contains a dot, so there's no overlap).
892
+ _SIGNED_FRAGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){2,}$")
893
+ # Access tokens carry the stable ``at_`` prefix and a lowercase
894
+ # base64url-ish charset (the server enforces the exact
895
+ # ``^at_[a-z0-9_-]{22}$`` form). We pin the prefix and charset — both
896
+ # stable — but leave the length open so a future token-length change
897
+ # doesn't make the SDK reject links the platform still mints. This is a
898
+ # cheap "looks like a token" sanity check; the server stays authoritative.
899
+ #
900
+ # INVARIANT: this pattern must stay a strict subset of ``_RESOURCE_ID_RE``.
901
+ # ``enter_portal`` validates the extracted token here (a non-echoing error)
902
+ # and then hands it to ``resolve`` → ``validate_id``, whose error DOES echo
903
+ # the value. As long as anything matching this pattern also matches
904
+ # ``_RESOURCE_ID_RE``, that echoing check never fires on the enter_portal
905
+ # path, so a credential can't leak into a ``validate_id`` error. Loosening
906
+ # this charset beyond ``_RESOURCE_ID_RE`` would reopen that leak.
907
+ _ACCESS_TOKEN_RE = re.compile(r"^at_[a-z0-9_-]+$")
908
+
909
+
910
+ def extract_access_token(qurl_link: str) -> str:
911
+ """Extract the access token from a qURL link, or pass a bare token through.
912
+
913
+ Platform-issued links carry the access token in the URL fragment
914
+ (``https://qurl.link/#at_...``); a bare ``at_...`` token is accepted
915
+ as-is. Error messages deliberately do not echo the input — qURL links
916
+ are credentials and must stay out of logs and tracebacks.
917
+ """
918
+ if not isinstance(qurl_link, str) or not qurl_link.strip():
919
+ raise ValueError("qurl_link: must be a non-empty string")
920
+ # Tolerate surrounding whitespace/newlines from a copy-paste.
921
+ qurl_link = qurl_link.strip()
922
+ # The platform fragment is exactly the token (qurl-service builds
923
+ # links as ``https://<domain>/#<tokenID>`` with nothing appended), so
924
+ # the whole fragment is the token. If links ever grew fragment
925
+ # parameters (``#at_xxx&ref=...``), this would need to parse the token
926
+ # out of the fragment rather than equate them.
927
+ token = qurl_link.split("#", 1)[1] if "#" in qurl_link else qurl_link
928
+ if _SIGNED_FRAGMENT_RE.match(token):
929
+ raise ValueError(
930
+ "qurl_link: this is a signed qURL link (offline fragment format), "
931
+ "which cannot be opened through the API resolver — enter_portal "
932
+ "only accepts platform links (https://qurl.link/#at_...) or bare "
933
+ "access tokens"
934
+ )
935
+ if not _ACCESS_TOKEN_RE.match(token):
936
+ raise ValueError(
937
+ "qurl_link: no access token found — pass a platform qURL link "
938
+ "(https://qurl.link/#at_...) or a bare access token (at_...)"
939
+ )
940
+ return token
941
+
942
+
943
+ def resource_handle_from_resolve(out: ResolveOutput) -> ResourceHandle:
944
+ """Map a ResolveOutput onto the portal-verb ResourceHandle.
945
+
946
+ Fails closed like qurl-go's ``EnterPortal``: a grant that carries no
947
+ resource URL is not actionable, so raise instead of handing back an
948
+ empty handle. Uses the same client-detected-failure convention as
949
+ the batch_create shape guard (``status=0``, ``unexpected_response``).
950
+ """
951
+ if not out.target_url:
952
+ raise ValidationError(
953
+ status=0,
954
+ code="unexpected_response",
955
+ title="Unexpected Response",
956
+ detail="enter_portal: access was granted but the API returned no resource URL",
957
+ )
958
+ open_seconds = out.access_grant.expires_in if out.access_grant else 0
959
+ return ResourceHandle(
960
+ resource_url=out.target_url,
961
+ open_seconds=open_seconds,
962
+ resource_id=out.resource_id,
963
+ )
964
+
965
+
662
966
  def parse_quota(data: dict[str, Any]) -> Quota:
663
967
  """Parse a Quota from API response data."""
664
968
  return Quota(
@@ -686,7 +990,6 @@ def _parse_rate_limits(data: dict[str, Any] | None) -> RateLimits | None:
686
990
  list_per_minute=data.get("list_per_minute", 0),
687
991
  resolve_per_minute=data.get("resolve_per_minute", 0),
688
992
  max_active_qurls=data.get("max_active_qurls", 0),
689
- max_tokens_per_qurl=data.get("max_tokens_per_qurl", 0),
690
993
  max_expiry_seconds=data.get("max_expiry_seconds", 0),
691
994
  )
692
995
 
@@ -1133,22 +1436,6 @@ def parse_connector_installation_list_output(
1133
1436
  )
1134
1437
 
1135
1438
 
1136
- def parse_agent_bootstrap_output(data: dict[str, Any]) -> AgentBootstrapOutput:
1137
- """Parse connector agent bootstrap output."""
1138
- raw_peer = data.get("nhp_server_peer")
1139
- peer = raw_peer if isinstance(raw_peer, dict) else {}
1140
- return AgentBootstrapOutput(
1141
- agent_id=data.get("agent_id", ""),
1142
- registered_at=_parse_dt(data.get("registered_at")),
1143
- nhp_server_peer=NHPServerPeerInfo(
1144
- public_key_b64=peer.get("public_key_b64", ""),
1145
- host=peer.get("host", ""),
1146
- port=peer.get("port", 0),
1147
- expire_time=peer.get("expire_time", 0),
1148
- ),
1149
- )
1150
-
1151
-
1152
1439
  def _validate_batch_create_shape(data: Any) -> None:
1153
1440
  """Defense-in-depth structural check for batch_create responses.
1154
1441