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