qurl-python 0.3.0__py3-none-any.whl → 0.5.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
@@ -45,6 +45,7 @@ from layerv_qurl.types import (
45
45
  ConnectorInstallationListOutput,
46
46
  ConnectorInstallationStats,
47
47
  CreateOutput,
48
+ CredentialClaim,
48
49
  CurrentPeriodUsage,
49
50
  Customer,
50
51
  DailyUsage,
@@ -58,6 +59,7 @@ from layerv_qurl.types import (
58
59
  MintOutput,
59
60
  Portal,
60
61
  PortalSession,
62
+ Publisher,
61
63
  Quota,
62
64
  QuotaPlan,
63
65
  QURLStatus,
@@ -72,6 +74,7 @@ from layerv_qurl.types import (
72
74
  Session,
73
75
  SessionListOutput,
74
76
  SessionTerminateOutput,
77
+ ShareLink,
75
78
  TokenStatus,
76
79
  Usage,
77
80
  UsageCostEstimate,
@@ -129,6 +132,8 @@ __all__ = [
129
132
  "InvoiceListOutput",
130
133
  "Portal",
131
134
  "PortalSession",
135
+ "Publisher",
136
+ "ShareLink",
132
137
  "QuotaPlan",
133
138
  "QURLStatus",
134
139
  "RedeemAccessCodeOutput",
@@ -154,6 +159,7 @@ __all__ = [
154
159
  "AccessPolicy",
155
160
  "AccessToken",
156
161
  "CreateOutput",
162
+ "CredentialClaim",
157
163
  "ListOutput",
158
164
  "MintOutput",
159
165
  "QURL",
@@ -0,0 +1,54 @@
1
+ """Cursor traversal shared by the synchronous and asynchronous clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol, TypeVar
6
+
7
+ from layerv_qurl.errors import QURLError
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
11
+
12
+
13
+ class CursorPage(Protocol):
14
+ next_cursor: str | None
15
+ has_more: bool
16
+
17
+
18
+ Page = TypeVar("Page", bound=CursorPage)
19
+
20
+
21
+ def _next_cursor(page: CursorPage, seen: set[str]) -> str | None:
22
+ if not page.has_more:
23
+ return None
24
+ cursor = page.next_cursor
25
+ if not isinstance(cursor, str) or not cursor or cursor in seen:
26
+ raise QURLError(
27
+ status=0,
28
+ code="unexpected_response",
29
+ title="Pagination requires a new non-empty cursor when has_more is true",
30
+ )
31
+ seen.add(cursor)
32
+ return cursor
33
+
34
+
35
+ def pages(fetch: Callable[[str | None], Page]) -> Iterator[Page]:
36
+ cursor = None
37
+ seen: set[str] = set()
38
+ while True:
39
+ page = fetch(cursor)
40
+ yield page
41
+ cursor = _next_cursor(page, seen)
42
+ if cursor is None:
43
+ return
44
+
45
+
46
+ async def async_pages(fetch: Callable[[str | None], Awaitable[Page]]) -> AsyncIterator[Page]:
47
+ cursor = None
48
+ seen: set[str] = set()
49
+ while True:
50
+ page = await fetch(cursor)
51
+ yield page
52
+ cursor = _next_cursor(page, seen)
53
+ if cursor is None:
54
+ return
layerv_qurl/_utils.py CHANGED
@@ -10,6 +10,7 @@ not import them directly.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import base64
13
14
  import dataclasses
14
15
  import functools
15
16
  import logging
@@ -17,6 +18,7 @@ import random
17
18
  import re
18
19
  import secrets
19
20
  import time
21
+ import unicodedata
20
22
  import uuid
21
23
  from collections.abc import Iterable, Mapping, Set
22
24
  from datetime import datetime, timedelta
@@ -25,6 +27,9 @@ from importlib.metadata import version as _pkg_version
25
27
  from typing import TYPE_CHECKING, Any, TypeVar
26
28
  from urllib.parse import quote, urlsplit
27
29
 
30
+ import httpx
31
+
32
+ from layerv_qurl.crid import key_matches
28
33
  from layerv_qurl.errors import (
29
34
  AuthenticationError,
30
35
  AuthorizationError,
@@ -56,6 +61,7 @@ from layerv_qurl.types import (
56
61
  ConnectorInstallationListOutput,
57
62
  ConnectorInstallationStats,
58
63
  CreateOutput,
64
+ CredentialClaim,
59
65
  CurrentPeriodUsage,
60
66
  Customer,
61
67
  DailyUsage,
@@ -69,6 +75,7 @@ from layerv_qurl.types import (
69
75
  MintOutput,
70
76
  Portal,
71
77
  PortalSession,
78
+ Publisher,
72
79
  Quota,
73
80
  RateLimits,
74
81
  RedeemAccessCodeOutput,
@@ -80,6 +87,7 @@ from layerv_qurl.types import (
80
87
  Session,
81
88
  SessionListOutput,
82
89
  SessionTerminateOutput,
90
+ ShareLink,
83
91
  Usage,
84
92
  UsageCostEstimate,
85
93
  UsageDailyEntry,
@@ -95,7 +103,6 @@ from layerv_qurl.types import (
95
103
  if TYPE_CHECKING:
96
104
  from collections.abc import Callable
97
105
 
98
- import httpx
99
106
 
100
107
  logger = logging.getLogger("layerv_qurl")
101
108
  _T = TypeVar("_T")
@@ -104,9 +111,9 @@ DEFAULT_BASE_URL = "https://api.layerv.ai"
104
111
  DEFAULT_TIMEOUT = 30.0
105
112
  DEFAULT_MAX_RETRIES = 3
106
113
  RETRYABLE_STATUS = {429, 502, 503, 504}
107
- # POST requests still only retry rate limits: resolve can consume one-time
108
- # tokens after an NHP knock failure, and service errors are not cached.
109
- RETRYABLE_STATUS_POST = {429}
114
+ # POST and PATCH can consume tokens or extend expiry after committing;
115
+ # service 5xx responses are not cached for durable replay.
116
+ RETRYABLE_STATUS_NON_IDEMPOTENT = {429}
110
117
  # POST/PATCH, plus any future PUT endpoints from the API contract, carry a
111
118
  # qurl-service replay key so retried writes are tied to one logical operation.
112
119
  # DELETE keeps the wider HTTP retry set without a key because repeated deletes
@@ -144,10 +151,11 @@ def default_user_agent() -> str:
144
151
  return f"qurl-python-sdk/{v}"
145
152
 
146
153
 
147
- def validate_id(value: str, name: str = "resource_id") -> str:
154
+ def validate_id(value: str, name: str = "resource_id", *, sensitive: bool = False) -> str:
148
155
  """Validate that an ID is non-empty and contains no path traversal characters."""
149
- if not value or not _RESOURCE_ID_RE.match(value):
150
- raise ValueError(f"Invalid {name}: {value!r}")
156
+ if not value or not _RESOURCE_ID_RE.fullmatch(value):
157
+ shown = "<redacted>" if sensitive else repr(value)
158
+ raise ValueError(f"Invalid {name}: {shown}")
151
159
  return value
152
160
 
153
161
 
@@ -197,7 +205,33 @@ def build_body(kwargs: dict[str, Any]) -> dict[str, Any]:
197
205
  return body
198
206
 
199
207
 
200
- def build_string_list(value: Any, field: str) -> list[str]:
208
+ def build_credential_scopes(
209
+ kind: str, scopes: Any, target: Any = None, claims: Any = None
210
+ ) -> list[str] | None:
211
+ """Validate the kind-dependent credential fields and return ``scopes``.
212
+
213
+ The three fields are mutually exclusive by kind, and the server
214
+ returns 400 ``invalid_input`` for either mismatch. Account ``api_key``
215
+ credentials carry caller-chosen ``scopes`` and accept neither
216
+ ``target`` nor ``claims``; ``enrollment_token`` scopes are
217
+ server-assigned from ``target``, so sending ``scopes`` is rejected.
218
+ """
219
+ if kind == "enrollment_token":
220
+ if scopes is not None:
221
+ raise ValueError(
222
+ "scopes: not accepted for kind='enrollment_token'; the server "
223
+ "assigns them from target"
224
+ )
225
+ return None
226
+ for field, value in (("target", target), ("claims", claims)):
227
+ if value is not None:
228
+ raise ValueError(f"{field}: only accepted for kind='enrollment_token'")
229
+ if scopes is None:
230
+ raise ValueError(f"scopes: required for kind={kind!r}")
231
+ return build_string_list(scopes, "scopes")
232
+
233
+
234
+ def build_string_list(value: Any, field: str, *, allow_empty: bool = False) -> list[str]:
201
235
  """Build a non-empty list of strings without accepting strings or mappings."""
202
236
  if isinstance(value, str):
203
237
  raise ValueError(f"{field}: must be an iterable of strings, not a string")
@@ -208,7 +242,7 @@ def build_string_list(value: Any, field: str) -> list[str]:
208
242
  if not isinstance(value, Iterable):
209
243
  raise ValueError(f"{field}: must be an iterable of strings")
210
244
  items = list(value)
211
- if not items:
245
+ if not items and not allow_empty:
212
246
  raise ValueError(f"{field}: cannot be empty; pass at least one value")
213
247
  if any(not isinstance(item, str) for item in items):
214
248
  raise ValueError(f"{field}: all values must be strings")
@@ -323,6 +357,7 @@ def require_nonempty_update(body: dict[str, Any], method: str, fields: str) -> N
323
357
  MAX_TARGET_URL = 2048
324
358
  MAX_LABEL = 500
325
359
  MAX_DESCRIPTION = 500
360
+ MAX_RESOURCE_NAME = 100
326
361
  MAX_CUSTOM_DOMAIN = 253
327
362
  MAX_MAX_SESSIONS = 1000
328
363
  MAX_TAGS = 10
@@ -371,7 +406,6 @@ _RESERVED_ALIASES = frozenset(
371
406
  "*",
372
407
  }
373
408
  )
374
- RESOURCE_ID_PREFIX = "r_"
375
409
  # target_url must use an http(s) scheme per the API's SSRF protection.
376
410
  # This is a cheap client-side sanity check — the server is still the
377
411
  # authoritative validator (e.g. it rejects localhost, cloud metadata,
@@ -452,9 +486,26 @@ def _require_valid_tags(tags: list[str] | None) -> None:
452
486
  )
453
487
 
454
488
 
489
+ def validate_resource_name(name: str | None) -> None:
490
+ """Mirror the API's ValidatePreviewName rule (100 characters, one line, no hidden chars)."""
491
+ if name is None:
492
+ return
493
+ validate_required_string(name, "name")
494
+ _require_max_length(name, "name", MAX_RESOURCE_NAME)
495
+ # Same rule as the service's ValidatePreviewName (100 code points; refuses
496
+ # Cc, Cf -- including ZWJ emoji sequences -- U+2028/9 and padding).
497
+ if name.strip() != name or any(
498
+ unicodedata.category(c) in {"Cc", "Cf", "Zl", "Zp"} for c in name
499
+ ):
500
+ raise ValueError(
501
+ "name: must be one line without hidden characters or surrounding whitespace"
502
+ )
503
+
504
+
455
505
  def validate_create_input(
456
506
  *,
457
507
  target_url: str,
508
+ name: str | None = None,
458
509
  label: str | None = None,
459
510
  max_sessions: int | None = None,
460
511
  custom_domain: str | None = None,
@@ -469,6 +520,7 @@ def validate_create_input(
469
520
  # `target_url[:32]!r` trap for non-subscriptable inputs (None, int,
470
521
  # bool, ...), surfacing a clean ValueError instead of TypeError.
471
522
  _require_http_url(target_url, "target_url")
523
+ validate_resource_name(name)
472
524
  _require_max_length(label, "label", MAX_LABEL)
473
525
  _require_max_length(custom_domain, "custom_domain", MAX_CUSTOM_DOMAIN)
474
526
  _require_max_sessions_in_range(max_sessions)
@@ -507,29 +559,21 @@ def validate_mint_input(
507
559
  _require_max_sessions_in_range(max_sessions)
508
560
 
509
561
 
510
- def require_resource_id_prefix(resource_id: str, operation: str = "delete") -> None:
511
- """Enforce the ``r_`` prefix on endpoints that only accept resource IDs.
562
+ def validate_resource_qurl_ids(resource_id: str, qurl_id: str) -> None:
563
+ """Keep bearer tokens out of token-management paths and errors."""
564
+ validate_resource_locator(resource_id)
565
+ validate_id(qurl_id, "qurl_id", sensitive=True)
566
+ if not qurl_id.startswith("q_") or len(qurl_id) == 2:
567
+ raise ValueError("qurl_id must be the qURL display ID returned by the API")
512
568
 
513
- Per the OpenAPI spec, ``DELETE /v1/qurls/:id`` explicitly requires a
514
- resource ID (r_ prefix) — the token-scoped endpoint is
515
- ``DELETE /v1/resources/:id/qurls/:qurl_id``. Catch the common mistake
516
- of passing a ``q_`` display ID here with a clear client-side error.
517
- """
518
- if not resource_id.startswith(RESOURCE_ID_PREFIX):
519
- # Don't echo the raw ID — even truncated to 16 chars it may
520
- # contain caller-sensitive data that ends up in error logs.
521
- # Echo only the 2-char prefix so the caller sees which kind of
522
- # ID they passed without leaking the value.
523
- observed_prefix = resource_id[:2]
524
- # TODO: update the "not yet available in this SDK version"
525
- # wording once a token-scoped `revoke_token()` / equivalent
526
- # method lands on the client. Until then, the error points
527
- # users at the API-level endpoint without a concrete SDK call.
569
+
570
+ def validate_resource_locator(resource_id: str) -> None:
571
+ """Keep token IDs off resource-wide deletion; the API validates public keys."""
572
+ validate_id(resource_id, sensitive=True)
573
+ if resource_id.startswith(("q_", "at_")):
528
574
  raise ValueError(
529
- f"{operation}: only resource IDs ({RESOURCE_ID_PREFIX} prefix) are accepted — "
530
- f"got an ID starting with {observed_prefix!r}. To revoke a single "
531
- "access token, use the token-scoped revoke endpoint (not yet "
532
- "available in this SDK version)."
575
+ f"resource locator cannot start with {resource_id[:2]!r}; "
576
+ "use a CRID or resource public key, or revoke_resource_qurl for one token"
533
577
  )
534
578
 
535
579
 
@@ -750,13 +794,17 @@ def validate_portal_target_url(target_url: str) -> None:
750
794
  def build_protect_body(
751
795
  *,
752
796
  target_url: str,
797
+ name: str | None,
753
798
  description: str | None,
754
799
  tags: list[str] | None,
755
800
  custom_domain: str | None,
756
801
  alias: str | None,
802
+ private: bool | None = None,
757
803
  ) -> dict[str, Any]:
758
804
  """Validate protect-url inputs and build the create-resource request body."""
759
805
  validate_portal_target_url(target_url)
806
+ validate_resource_name(name)
807
+ validate_optional_bool(private, "private")
760
808
  validate_update_input(
761
809
  description=description, tags=tags, custom_domain=custom_domain
762
810
  )
@@ -764,10 +812,12 @@ def build_protect_body(
764
812
  return build_body(
765
813
  {
766
814
  "target_url": target_url,
815
+ "name": name,
767
816
  "description": description,
768
817
  "tags": tags,
769
818
  "custom_domain": custom_domain,
770
819
  "alias": alias,
820
+ "private": private,
771
821
  }
772
822
  )
773
823
 
@@ -775,11 +825,12 @@ def build_protect_body(
775
825
  def require_connector_resource(
776
826
  resources: list[Resource], connector_id: str
777
827
  ) -> Resource:
778
- """Select the single alias-matching resource from a connector lookup.
828
+ """Select the single slug-matching resource from a connector lookup.
779
829
 
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``)
830
+ Like qurl-go's ``GetConnectorResourceBySlug``, validate the immutable
831
+ slug rather than the optional, mutable alias. The lookup must return
832
+ exactly one resource whose slug equals the connector id.
833
+ Raises the portal surface's client-detected errors (``status=0``)
783
834
  otherwise — see :meth:`QURLClient.connector_resource` for the mapping.
784
835
 
785
836
  ASSUMPTION: ``resources`` is the complete result set for the slug —
@@ -809,14 +860,14 @@ def require_connector_resource(
809
860
  ),
810
861
  )
811
862
  resource = resources[0]
812
- if resource.alias != connector_id:
863
+ if resource.slug != connector_id:
813
864
  raise ValidationError(
814
865
  status=0,
815
866
  code="unexpected_response",
816
867
  title="Unexpected Response",
817
868
  detail=(
818
869
  f"connector_resource: connector {connector_id!r} returned a "
819
- "resource with a missing or different alias"
870
+ "resource with a missing or different slug"
820
871
  ),
821
872
  )
822
873
  return resource
@@ -873,8 +924,10 @@ def parse_portal(data: dict[str, Any]) -> Portal:
873
924
  title="Unexpected Response",
874
925
  detail="create portal: API response is missing resource_id or qurl_link",
875
926
  )
927
+ validate_response_identity(data)
876
928
  return Portal(
877
929
  resource_id=resource_id,
930
+ crid=data.get("crid"),
878
931
  link=qurl_link,
879
932
  site=data.get("qurl_site"),
880
933
  expires_at=_parse_dt(data.get("expires_at")),
@@ -989,8 +1042,10 @@ def _parse_rate_limits(data: dict[str, Any] | None) -> RateLimits | None:
989
1042
  create_per_hour=data.get("create_per_hour", 0),
990
1043
  list_per_minute=data.get("list_per_minute", 0),
991
1044
  resolve_per_minute=data.get("resolve_per_minute", 0),
992
- max_active_qurls=data.get("max_active_qurls", 0),
1045
+ max_active_qurls=data.get("max_active_qurls"),
993
1046
  max_expiry_seconds=data.get("max_expiry_seconds", 0),
1047
+ max_active_resources=data.get("max_active_resources"),
1048
+ max_data_transfer_bytes=data.get("max_data_transfer_bytes"),
994
1049
  )
995
1050
 
996
1051
 
@@ -999,11 +1054,14 @@ def _parse_usage_block(data: dict[str, Any] | None) -> Usage | None:
999
1054
  return None
1000
1055
  return Usage(
1001
1056
  qurls_created=data.get("qurls_created", 0),
1002
- active_qurls=data.get("active_qurls", 0),
1057
+ active_qurls=data.get("active_qurls"),
1003
1058
  # Nullable per the API spec — the field is null when
1004
1059
  # max_active_qurls is unlimited.
1005
1060
  active_qurls_percent=data.get("active_qurls_percent"),
1006
1061
  total_accesses=data.get("total_accesses", 0),
1062
+ active_resources=data.get("active_resources"),
1063
+ active_resources_percent=data.get("active_resources_percent"),
1064
+ data_transfer_bytes=data.get("data_transfer_bytes"),
1007
1065
  )
1008
1066
 
1009
1067
 
@@ -1017,8 +1075,23 @@ def parse_list_output(data: Any, meta: dict[str, Any] | None) -> ListOutput:
1017
1075
  def _parse_resource(data: dict[str, Any], *, strict_identity: bool) -> Resource:
1018
1076
  resource_id = data["resource_id"] if strict_identity else data.get("resource_id", "")
1019
1077
  status = data["status"] if strict_identity else data.get("status", "unknown")
1078
+ validate_response_identity(data)
1079
+ private = data.get("private")
1080
+ access_requests = data.get("access_requests")
1081
+ for value in (private, access_requests):
1082
+ if value is not None and not isinstance(value, bool):
1083
+ raise unexpected_response("invalid resource privacy or access_requests")
1084
+ device_keys = data.get("allowed_device_keys")
1085
+ if device_keys is not None and (
1086
+ not isinstance(device_keys, list) or any(not isinstance(key, str) for key in device_keys)
1087
+ ):
1088
+ raise unexpected_response("invalid resource allowed_device_keys")
1020
1089
  return Resource(
1021
1090
  resource_id=resource_id,
1091
+ crid=data.get("crid"),
1092
+ private=private,
1093
+ access_requests=access_requests,
1094
+ allowed_device_keys=device_keys,
1022
1095
  status=status,
1023
1096
  resource_type=data.get("type"),
1024
1097
  target_url=data.get("target_url"),
@@ -1216,20 +1289,25 @@ def parse_webhook_event_types_output(data: Any) -> WebhookEventTypesOutput:
1216
1289
  return WebhookEventTypesOutput(events=events)
1217
1290
 
1218
1291
 
1292
+ def _parse_credential_claim(data: dict[str, Any]) -> CredentialClaim:
1293
+ return CredentialClaim(type=data.get("type", ""), id=data.get("id", ""))
1294
+
1295
+
1219
1296
  def parse_api_key(data: dict[str, Any]) -> APIKey:
1220
- """Parse API key metadata."""
1297
+ """Parse credential metadata."""
1221
1298
  return APIKey(
1222
1299
  key_id=data["key_id"],
1223
1300
  key_prefix=data["key_prefix"],
1224
1301
  name=data.get("name", ""),
1302
+ kind=data.get("kind"),
1225
1303
  scopes=data.get("scopes") or [],
1226
1304
  status=data.get("status"),
1227
1305
  created_at=_parse_dt(data.get("created_at")),
1228
1306
  updated_at=_parse_dt(data.get("updated_at")),
1229
1307
  last_used_at=_parse_dt(data.get("last_used_at")),
1230
1308
  expires_at=_parse_dt(data.get("expires_at")),
1231
- purpose=data.get("purpose"),
1232
- tunnel_slug=data.get("tunnel_slug"),
1309
+ target=data.get("target"),
1310
+ claims=_parse_list_items(data.get("claims"), _parse_credential_claim),
1233
1311
  api_key=data.get("api_key"),
1234
1312
  )
1235
1313
 
@@ -1264,12 +1342,15 @@ def parse_access_code(data: dict[str, Any]) -> AccessCode:
1264
1342
  def parse_access_code_list_output(
1265
1343
  data: Any, meta: dict[str, Any] | None
1266
1344
  ) -> AccessCodeListOutput:
1267
- """Parse an access-code list response."""
1345
+ """Require the complete, unpaginated access-code contract."""
1268
1346
  next_cursor, has_more = _meta_page(meta)
1269
- access_codes = _parse_list_items(data, parse_access_code)
1270
- return AccessCodeListOutput(
1271
- access_codes=access_codes, next_cursor=next_cursor, has_more=has_more
1272
- )
1347
+ if next_cursor or has_more:
1348
+ raise QURLError(
1349
+ status=0,
1350
+ code="unexpected_response",
1351
+ title="Access-code listing unexpectedly returned pagination metadata",
1352
+ )
1353
+ return AccessCodeListOutput(access_codes=_parse_list_items(data, parse_access_code))
1273
1354
 
1274
1355
 
1275
1356
  def _parse_usage_cost(data: dict[str, Any] | None) -> UsageCostEstimate | None:
@@ -1289,8 +1370,10 @@ def parse_current_period_usage(data: dict[str, Any]) -> CurrentPeriodUsage:
1289
1370
  period_start=_parse_dt(data.get("period_start")),
1290
1371
  period_end=_parse_dt(data.get("period_end")),
1291
1372
  qurls_created=data.get("qurls_created", 0),
1292
- active_qurls=data.get("active_qurls", 0),
1373
+ active_qurls=data.get("active_qurls"),
1293
1374
  cost_estimate=_parse_usage_cost(data.get("cost_estimate")),
1375
+ active_resources=data.get("active_resources"),
1376
+ data_transfer_bytes=data.get("data_transfer_bytes"),
1294
1377
  )
1295
1378
 
1296
1379
 
@@ -1639,6 +1722,13 @@ def parse_error(response: httpx.Response) -> QURLError:
1639
1722
  )
1640
1723
 
1641
1724
 
1725
+ def should_retry_transport(method: str, error: httpx.TransportError) -> bool:
1726
+ """Only replay POST/PATCH before sending; retain idempotent method retries."""
1727
+ return method not in {"POST", "PATCH"} or isinstance(
1728
+ error, (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
1729
+ )
1730
+
1731
+
1642
1732
  def retry_delay(attempt: int, last_error: Exception | None) -> float:
1643
1733
  """Compute retry delay with exponential backoff, jitter, and Retry-After cap."""
1644
1734
  if isinstance(last_error, QURLError) and last_error.retry_after:
@@ -1699,3 +1789,142 @@ def mask_key(api_key: str) -> str:
1699
1789
  if len(api_key) > 8:
1700
1790
  return api_key[:4] + "***" + api_key[-4:]
1701
1791
  return "***"
1792
+
1793
+
1794
+ def validate_optional_bool(value: bool | None, name: str) -> None:
1795
+ if value is not None and not isinstance(value, bool):
1796
+ raise ValueError(f"{name}: must be a bool")
1797
+
1798
+
1799
+ def check_response_crid(data: dict[str, Any], expected: str) -> None:
1800
+ if data.get("crid") != expected:
1801
+ raise unexpected_response("resource CRID is missing or differs from request")
1802
+
1803
+
1804
+ def build_share_body(ttl: timedelta | None, session_duration: timedelta | None) -> dict[str, Any]:
1805
+ body: dict[str, Any] = {}
1806
+ for name, value in (("ttl", ttl), ("session_duration", session_duration)):
1807
+ if value is None:
1808
+ continue
1809
+ if not isinstance(value, timedelta) or value < timedelta(0) or value.microseconds:
1810
+ raise ValueError(f"{name}: must be a nonnegative timedelta of whole seconds")
1811
+ seconds = value.days * 86400 + value.seconds
1812
+ if seconds:
1813
+ if name == "ttl":
1814
+ body["ttl_seconds"] = seconds
1815
+ else:
1816
+ body["session_duration"] = f"{seconds}s"
1817
+ return body
1818
+
1819
+
1820
+ def parse_share_link(data: dict[str, Any], expected: str) -> ShareLink:
1821
+ if not isinstance(data, dict):
1822
+ raise unexpected_response("share response must be an object")
1823
+ check_response_crid(data, expected)
1824
+ for field_name, expected_type in (
1825
+ ("qurl_id", str), ("type", str), ("expires_in_seconds", int), ("single_use", bool)
1826
+ ):
1827
+ value = data.get(field_name)
1828
+ if value is not None and type(value) is not expected_type:
1829
+ raise unexpected_response(f"invalid share field: {field_name}")
1830
+ if (data.get("expires_in_seconds") or 0) < 0:
1831
+ raise unexpected_response("negative share expires_in_seconds")
1832
+ link = data.get("qurl")
1833
+ if not isinstance(link, str) or not link.strip():
1834
+ raise unexpected_response("share response is missing qurl")
1835
+ publisher = data.get("publisher")
1836
+ if publisher is None:
1837
+ publisher = {}
1838
+ if not isinstance(publisher, dict):
1839
+ raise unexpected_response("invalid publisher metadata")
1840
+ name = publisher.get("name")
1841
+ if name is None:
1842
+ name = ""
1843
+ verified = publisher.get("verified")
1844
+ if not isinstance(name, str) or (verified is not None and not isinstance(verified, bool)):
1845
+ raise unexpected_response("invalid publisher metadata")
1846
+ return ShareLink(
1847
+ link=link, crid=expected, qurl_id=data.get("qurl_id") or None,
1848
+ type=data.get("type"), expires_at=parse_share_timestamp(data.get("expires_at")),
1849
+ expires_in_seconds=data.get("expires_in_seconds") or 0,
1850
+ single_use=data.get("single_use") is True,
1851
+ resource_created_at=parse_share_timestamp(data.get("resource_created_at")),
1852
+ publisher=Publisher(name=name, verified=verified is True),
1853
+ )
1854
+
1855
+
1856
+ def build_device_grants(
1857
+ *, allowed_device_keys: Iterable[str] | None = None,
1858
+ allowed_device_keys_add: Iterable[str] | None = None,
1859
+ allowed_device_keys_remove: Iterable[str] | None = None,
1860
+ custom_domain: str | None = None,
1861
+ ) -> dict[str, Any]:
1862
+ """Preserve explicit empty lists; validate the API's two grant update forms."""
1863
+ body: dict[str, Any] = {}
1864
+ for name, value in (("allowed_device_keys", allowed_device_keys),
1865
+ ("allowed_device_keys_add", allowed_device_keys_add),
1866
+ ("allowed_device_keys_remove", allowed_device_keys_remove)):
1867
+ if value is None:
1868
+ continue
1869
+ keys = build_string_list(value, name, allow_empty=True)
1870
+ if len(keys) > 256 or len(set(keys)) != len(keys):
1871
+ raise ValueError(f"{name}: at most 256 unique keys are allowed")
1872
+ for key in keys:
1873
+ try:
1874
+ decoded = base64.b64decode(key, validate=True)
1875
+ except ValueError:
1876
+ raise ValueError(
1877
+ f"{name}: keys must be canonical base64 X25519 public keys"
1878
+ ) from None
1879
+ if len(decoded) != 32 or base64.b64encode(decoded).decode() != key:
1880
+ raise ValueError(f"{name}: keys must be canonical base64 X25519 public keys")
1881
+ body[name] = keys
1882
+ if "allowed_device_keys" in body and len(body) > 1:
1883
+ raise ValueError("allowed_device_keys cannot be combined with add/remove")
1884
+ if body and custom_domain is not None:
1885
+ raise ValueError("device grants and custom_domain must be updated separately")
1886
+ added = set(body.get("allowed_device_keys_add", []))
1887
+ if added & set(body.get("allowed_device_keys_remove", [])):
1888
+ raise ValueError("a device key cannot be added and removed together")
1889
+ return body
1890
+
1891
+
1892
+ def unexpected_response(detail: str) -> ValidationError:
1893
+ return ValidationError(status=0, code="unexpected_response", title="Unexpected Response",
1894
+ detail=detail)
1895
+
1896
+
1897
+ def validate_response_identity(data: dict[str, Any]) -> None:
1898
+ """Bind a reported CRID to its resource key, matching Go's resource parser.
1899
+
1900
+ Older responses omit CRID; REST compatibility callers can still use their
1901
+ resource_id. A reported CRID must never be accepted without its key binding.
1902
+ """
1903
+ crid = data.get("crid")
1904
+ if crid is None:
1905
+ return
1906
+ key = data.get("resource_id")
1907
+ if not isinstance(crid, str) or not isinstance(key, str):
1908
+ raise unexpected_response("invalid resource identity")
1909
+ try:
1910
+ der = base64.b64decode(key + "=" * (-len(key) % 4), altchars=b"-_", validate=True)
1911
+ canonical = base64.urlsafe_b64encode(der).decode().rstrip("=") == key
1912
+ if not canonical or not key_matches(crid, der):
1913
+ raise ValueError("key binding")
1914
+ except ValueError:
1915
+ raise unexpected_response("invalid resource key-bound CRID") from None
1916
+
1917
+
1918
+ def parse_share_timestamp(value: Any) -> datetime | None:
1919
+ if value is None:
1920
+ return None
1921
+ if not isinstance(value, str) or not re.fullmatch(
1922
+ r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}"
1923
+ r"(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})", value
1924
+ ):
1925
+ raise unexpected_response("invalid share timestamp")
1926
+ try:
1927
+ parsed = _parse_dt(value)
1928
+ except ValueError:
1929
+ raise unexpected_response("invalid share timestamp") from None
1930
+ return None if value == "0001-01-01T00:00:00Z" else parsed