qurl-python 0.2.1__tar.gz → 0.2.2__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.
Files changed (41) hide show
  1. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/ci.yml +2 -2
  2. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/dependency-age-check-actions.yml +1 -1
  3. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/issue-priority.yml +1 -1
  4. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/pr-title.yml +1 -1
  5. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/release-please.yml +2 -2
  6. qurl_python-0.2.2/.release-please-manifest.json +1 -0
  7. {qurl_python-0.2.1 → qurl_python-0.2.2}/CHANGELOG.md +7 -0
  8. {qurl_python-0.2.1 → qurl_python-0.2.2}/PKG-INFO +140 -32
  9. {qurl_python-0.2.1 → qurl_python-0.2.2}/README.md +139 -31
  10. {qurl_python-0.2.1 → qurl_python-0.2.2}/pyproject.toml +1 -1
  11. {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/__init__.py +15 -3
  12. {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/_utils.py +308 -2
  13. {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/async_client.py +244 -1
  14. {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/client.py +362 -11
  15. {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/types.py +34 -0
  16. qurl_python-0.2.2/tests/test_portal.py +733 -0
  17. qurl_python-0.2.1/.release-please-manifest.json +0 -1
  18. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  19. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  20. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  21. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  22. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/dependabot.yml +0 -0
  23. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/claude-code-review.yml +0 -0
  24. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/claude.yml +0 -0
  25. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/codeql.yml +0 -0
  26. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/dependency-age-check-pip.yml +0 -0
  27. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/dependency-review.yml +0 -0
  28. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/secrets-scan.yml +0 -0
  29. {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/validate-issue-templates.yml +0 -0
  30. {qurl_python-0.2.1 → qurl_python-0.2.2}/.gitignore +0 -0
  31. {qurl_python-0.2.1 → qurl_python-0.2.2}/CLAUDE.md +0 -0
  32. {qurl_python-0.2.1 → qurl_python-0.2.2}/CONTRIBUTING.md +0 -0
  33. {qurl_python-0.2.1 → qurl_python-0.2.2}/LICENSE +0 -0
  34. {qurl_python-0.2.1 → qurl_python-0.2.2}/SECURITY.md +0 -0
  35. {qurl_python-0.2.1 → qurl_python-0.2.2}/release-please-config.json +0 -0
  36. {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/errors.py +0 -0
  37. {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/langchain.py +0 -0
  38. {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/py.typed +0 -0
  39. {qurl_python-0.2.1 → qurl_python-0.2.2}/tests/__init__.py +0 -0
  40. {qurl_python-0.2.1 → qurl_python-0.2.2}/tests/test_client.py +0 -0
  41. {qurl_python-0.2.1 → qurl_python-0.2.2}/tests/test_langchain.py +0 -0
@@ -18,7 +18,7 @@ jobs:
18
18
  steps:
19
19
  - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
20
20
  - name: Set up Python ${{ matrix.python-version }}
21
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
21
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
22
22
  with:
23
23
  python-version: ${{ matrix.python-version }}
24
24
  - name: Install dependencies
@@ -41,7 +41,7 @@ jobs:
41
41
  steps:
42
42
  - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
43
43
  - name: Set up Python 3.10
44
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
44
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
45
45
  with:
46
46
  python-version: "3.10"
47
47
  - name: Install dependencies
@@ -35,7 +35,7 @@ jobs:
35
35
  # only if/name/needs/permissions/secrets/strategy/uses/with/concurrency
36
36
  # are valid on a job that calls a reusable workflow. The reusable
37
37
  # workflow itself sets `timeout-minutes: 5` internally.
38
- uses: layervai/ops-routines-workflows/.github/workflows/age-check-actions.yml@9b72a931d25f18a3a713006963072296a0b28134 # v0.8.0
38
+ uses: layervai/ops-routines-workflows/.github/workflows/age-check-actions.yml@1536c698add5777aa08c781bc674d1eba469b32d # v0.9.0
39
39
  with:
40
40
  min_age_days: 7
41
41
  # Post a sticky PR comment when an age-check fails, listing each
@@ -48,6 +48,6 @@ jobs:
48
48
  # reusable's own `sender.type != 'Bot'` check short-circuits them
49
49
  # before setFailed — net effect is a near-instant no-op run.
50
50
  if: ${{ github.actor != 'github-actions[bot]' }}
51
- uses: layervai/ops-routines-workflows/.github/workflows/issue-priority.yml@9b72a931d25f18a3a713006963072296a0b28134 # v0.8.0
51
+ uses: layervai/ops-routines-workflows/.github/workflows/issue-priority.yml@1536c698add5777aa08c781bc674d1eba469b32d # v0.9.0
52
52
  permissions:
53
53
  issues: write
@@ -11,7 +11,7 @@ jobs:
11
11
  validate:
12
12
  runs-on: ubuntu-latest
13
13
  steps:
14
- - uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5.5.3
14
+ - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v5.5.3
15
15
  env:
16
16
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
17
17
  with:
@@ -41,7 +41,7 @@ jobs:
41
41
  with:
42
42
  ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
43
43
  - name: Set up Python ${{ matrix.python-version }}
44
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
44
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
45
45
  with:
46
46
  python-version: ${{ matrix.python-version }}
47
47
  - name: Install dependencies
@@ -62,7 +62,7 @@ jobs:
62
62
  with:
63
63
  ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
64
64
  - name: Set up Python
65
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
65
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
66
66
  with:
67
67
  python-version: "3.12"
68
68
  - name: Install build tools
@@ -0,0 +1 @@
1
+ {".":"0.2.2"}
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.2](https://github.com/layervai/qurl-python/compare/v0.2.1...v0.2.2) (2026-07-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * add portal-verb API surface mirroring qurl-go ([#95](https://github.com/layervai/qurl-python/issues/95)) ([2b5c96d](https://github.com/layervai/qurl-python/commit/2b5c96d4c5deed6368cc088cf9a18b9e2e0ed18e))
9
+
3
10
  ## [0.2.1](https://github.com/layervai/qurl-python/compare/v0.2.0...v0.2.1) (2026-06-28)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qurl-python
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Python SDK for the qURL™ API — secure, time-limited access links. Quantum URL is how you enter the hidden layer of the internet.
5
5
  Project-URL: Homepage, https://github.com/layervai/qurl-python
6
6
  Project-URL: Repository, https://github.com/layervai/qurl-python
@@ -38,15 +38,35 @@ Description-Content-Type: text/markdown
38
38
  [![Python](https://img.shields.io/pypi/pyversions/qurl-python)](https://pypi.org/project/qurl-python/)
39
39
  [![License](https://img.shields.io/github/license/layervai/qurl-python)](LICENSE)
40
40
 
41
- Python SDK for the [qURL™ API](https://docs.layerv.ai) — secure, time-limited access links for AI agents.
41
+ **Use the LayerV [qURL™ Platform](https://docs.layerv.ai) from Python: protect a
42
+ private URL once, then mint short-lived portal links for it.**
43
+
44
+ LayerV hosts qURL; your app keeps a tiny surface area: protect the URL, create a
45
+ portal for the returned resource, and share the link.
42
46
 
43
47
  > **Quantum URL (qURL)** · The internet has a hidden layer. This is how you enter.
44
48
 
45
- ## Why qURL?
49
+ Portal recipients do not need LayerV credentials, API keys, or SDK state. They
50
+ open the qURL link. Credentials are only for software that protects URLs or
51
+ creates portals.
46
52
 
47
- AI agents need to access APIs, databases, and internal tools — but permanent credentials are a security risk. qURL creates **time-limited, auditable access links** that automatically expire:
53
+ ## Why qURL?
48
54
 
49
- - **Time-limited** — links expire after minutes, hours, or days
55
+ Agents and services increasingly need to reach private MCP servers, APIs, and
56
+ internal tools. The issue is visibility: every standing public endpoint becomes
57
+ inventory for scanners, fingerprinting, credential attacks, and AI-assisted
58
+ probing before a legitimate user or agent ever arrives.
59
+
60
+ Opening an inbound port, running a VPN, shipping a bastion, publishing a
61
+ Cloudflare Tunnel or ngrok URL, or passing around a long-lived key all leave
62
+ something durable to find, scan, or steal. qURL flips that model. It is an
63
+ **invisibility primitive for authenticated access**, not another externally
64
+ visible endpoint in front of the same service. The private resource is not
65
+ public inventory. A portal is **cryptographic, just-in-time permission for one
66
+ actor to reach one private resource** — without turning that resource into
67
+ public inventory:
68
+
69
+ - **Time-limited** — portals expire after minutes, hours, or days
50
70
  - **IP-scoped** — access is granted only to the requesting IP via NHP
51
71
  - **Auditable** — every access is logged with who, when, and from where
52
72
  - **Revocable** — kill access instantly if something goes wrong
@@ -63,30 +83,125 @@ For LangChain integration:
63
83
  pip install qurl-python[langchain]
64
84
  ```
65
85
 
66
- ## Quick Start
86
+ ## Quickstart
67
87
 
68
88
  ```python
69
89
  from layerv_qurl import QURLClient
70
90
 
71
91
  client = QURLClient(api_key="lv_live_xxx")
72
92
 
73
- # Create a protected link
93
+ resource = client.protect_url("https://internal.example.com/dashboard")
94
+ portal = resource.create_portal(valid_for="5m")
95
+
96
+ print(portal.link) # Share this link — recipients need no credentials
97
+ ```
98
+
99
+ That is the core flow:
100
+
101
+ | Step | Call | What you provide |
102
+ | --- | --- | --- |
103
+ | Protect a private URL | `client.protect_url` | The target URL you already know |
104
+ | Mint a short-lived access link | `resource.create_portal` | The returned resource handle |
105
+
106
+ `protect_url` is idempotent for the same account and target URL: protecting
107
+ the same URL again returns the existing resource. `valid_for` accepts a
108
+ duration string (`"5m"`, `"24h"`) or a `datetime.timedelta`; prefer short
109
+ portal lifetimes.
110
+
111
+ If qURL Connector already protects the service, use the connector id instead
112
+ of calling `protect_url`:
113
+
114
+ ```python
115
+ resource = client.connector_resource("prod-dashboard")
116
+ portal = resource.create_portal(valid_for="5m")
117
+ ```
118
+
119
+ If you persist the resource id, future calls do not need to recreate the
120
+ handle (no API call is made until you mint):
121
+
122
+ ```python
123
+ resource = client.resource_by_id("r_demo1234567")
124
+ portal = resource.create_portal(valid_for="1h")
125
+ ```
126
+
127
+ For one-off scripts, `client.create_portal_for_url` combines the two API calls
128
+ and returns both the portal and a reusable resource handle:
129
+
130
+ ```python
131
+ portal, resource = client.create_portal_for_url(
132
+ "https://internal.example.com/dashboard", valid_for="5m"
133
+ )
134
+ ```
135
+
136
+ Portal options mirror qurl-go:
137
+
138
+ ```python
139
+ from datetime import timedelta
140
+
141
+ portal = resource.create_portal(
142
+ valid_for=timedelta(minutes=5),
143
+ label="Alice from Acme",
144
+ one_time_use=True,
145
+ max_sessions=1,
146
+ )
147
+ ```
148
+
149
+ ## Opening Portals
150
+
151
+ Most recipients open qURL links directly and do not use this SDK at all. If
152
+ you are building a service or agent that opens received qURL links
153
+ programmatically, `enter_portal` accepts a full link or a bare access token,
154
+ grants network access for the caller's IP, and returns the reachable resource:
155
+
156
+ ```python
157
+ handle = client.enter_portal(link)
158
+ print(handle.resource_url) # The reachable resource location
159
+ print(handle.open_seconds) # How long access stays open
160
+ ```
161
+
162
+ Unlike qurl-go's offline `EnterPortal`, this SDK opens links through the
163
+ LayerV API: the client needs an API key with the `qurl:resolve` scope.
164
+ `enter_portal` fails closed — if access is granted but no resource URL comes
165
+ back, it raises instead of returning an empty handle.
166
+
167
+ ## Async Usage
168
+
169
+ ```python
170
+ import asyncio
171
+ from layerv_qurl import AsyncQURLClient
172
+
173
+ async def main():
174
+ async with AsyncQURLClient(api_key="lv_live_xxx") as client:
175
+ resource = await client.protect_url("https://internal.example.com/dashboard")
176
+ portal = await resource.create_portal(valid_for="5m")
177
+ print(portal.link)
178
+
179
+ asyncio.run(main())
180
+ ```
181
+
182
+ ## REST-Shaped API (Compatibility)
183
+
184
+ The original REST-shaped methods remain fully supported and share the same
185
+ client. Use them for the qURL/resource/token management surface that has no
186
+ portal-verb equivalent (listing, updating, revoking, quotas, webhooks, ...) or
187
+ if you already build on them:
188
+
189
+ ```python
190
+ # Create a protected link (portal equivalent: create_portal_for_url)
74
191
  result = client.create(
75
192
  target_url="https://api.example.com/data",
76
193
  expires_in="24h",
77
194
  label="API access for agent",
78
195
  )
79
- print(result.qurl_link) # Share this link
196
+ print(result.qurl_link)
80
197
 
81
- # Resolve a token (grants network access for your IP)
198
+ # Resolve a token headlessly (portal equivalent: enter_portal)
82
199
  access = client.resolve("at_k8xqp9h2sj9lx7r4a")
83
200
  print(f"Access granted to {access.target_url} for {access.access_grant.expires_in}s")
84
201
 
85
- # Extend a qURL's expiration
202
+ # Extend a qURL's expiration and update metadata
86
203
  qurl = client.extend("r_xxx", "7d")
87
-
88
- # Update resource metadata
89
- qurl = client.update("r_xxx", description="extended", extend_by="7d")
204
+ qurl = client.update("r_xxx", description="extended")
90
205
  ```
91
206
 
92
207
  ## Authentication Notes
@@ -114,23 +229,6 @@ Fields such as webhook `events` and API-key `scopes` accept ordered non-string
114
229
  iterables of strings. Lists, tuples, and generators preserve the caller's
115
230
  iteration order; sets are rejected because their iteration order is not stable.
116
231
 
117
- ## Async Usage
118
-
119
- ```python
120
- import asyncio
121
- from layerv_qurl import AsyncQURLClient
122
-
123
- async def main():
124
- async with AsyncQURLClient(api_key="lv_live_xxx") as client:
125
- result = await client.create(target_url="https://example.com", expires_in="1h")
126
- access = await client.resolve("at_...")
127
-
128
- # Extend expiration
129
- qurl = await client.extend("r_xxx", "7d")
130
-
131
- asyncio.run(main())
132
- ```
133
-
134
232
  ## Pagination
135
233
 
136
234
  ```python
@@ -203,13 +301,13 @@ from layerv_qurl.errors import (
203
301
  client = QURLClient(api_key="lv_live_xxx")
204
302
 
205
303
  try:
206
- client.resolve("at_k8xqp9h2sj9lx7r4a")
304
+ client.enter_portal("https://qurl.link/#at_k8xqp9h2sj9lx7r4a")
207
305
  except AuthenticationError:
208
306
  print("Bad API key")
209
307
  except AuthorizationError:
210
308
  print("Valid key but missing qurl:resolve scope")
211
309
  except NotFoundError:
212
- print("Token doesn't exist or already expired")
310
+ print("Portal doesn't exist or already expired")
213
311
  except RateLimitError as e:
214
312
  print(f"Rate limited — retry in {e.retry_after}s")
215
313
  except ValidationError as e:
@@ -277,6 +375,16 @@ tools = toolkit.get_tools() # [CreateQURLTool, ResolveQURLTool, ListQURLsTool,
277
375
  | `user_agent` | No | `qurl-python-sdk/<version>` |
278
376
  | `http_client` | No | Auto-created `httpx.Client` |
279
377
 
378
+ ## Security Notes
379
+
380
+ - Treat API keys and qURL links like credentials. Do not log them.
381
+ - Prefer short portal lifetimes such as `valid_for="5m"`.
382
+ - Do not ask portal recipients to handle credentials. Recipients only need
383
+ the link.
384
+ - `protect_url` and `create_portal_for_url` reject malformed target URLs and
385
+ URLs with embedded credentials (`https://user:pass@...`) before any request,
386
+ matching qurl-go.
387
+
280
388
  ## License
281
389
 
282
390
  MIT
@@ -5,15 +5,35 @@
5
5
  [![Python](https://img.shields.io/pypi/pyversions/qurl-python)](https://pypi.org/project/qurl-python/)
6
6
  [![License](https://img.shields.io/github/license/layervai/qurl-python)](LICENSE)
7
7
 
8
- Python SDK for the [qURL™ API](https://docs.layerv.ai) — secure, time-limited access links for AI agents.
8
+ **Use the LayerV [qURL™ Platform](https://docs.layerv.ai) from Python: protect a
9
+ private URL once, then mint short-lived portal links for it.**
10
+
11
+ LayerV hosts qURL; your app keeps a tiny surface area: protect the URL, create a
12
+ portal for the returned resource, and share the link.
9
13
 
10
14
  > **Quantum URL (qURL)** · The internet has a hidden layer. This is how you enter.
11
15
 
12
- ## Why qURL?
16
+ Portal recipients do not need LayerV credentials, API keys, or SDK state. They
17
+ open the qURL link. Credentials are only for software that protects URLs or
18
+ creates portals.
13
19
 
14
- AI agents need to access APIs, databases, and internal tools — but permanent credentials are a security risk. qURL creates **time-limited, auditable access links** that automatically expire:
20
+ ## Why qURL?
15
21
 
16
- - **Time-limited** — links expire after minutes, hours, or days
22
+ Agents and services increasingly need to reach private MCP servers, APIs, and
23
+ internal tools. The issue is visibility: every standing public endpoint becomes
24
+ inventory for scanners, fingerprinting, credential attacks, and AI-assisted
25
+ probing before a legitimate user or agent ever arrives.
26
+
27
+ Opening an inbound port, running a VPN, shipping a bastion, publishing a
28
+ Cloudflare Tunnel or ngrok URL, or passing around a long-lived key all leave
29
+ something durable to find, scan, or steal. qURL flips that model. It is an
30
+ **invisibility primitive for authenticated access**, not another externally
31
+ visible endpoint in front of the same service. The private resource is not
32
+ public inventory. A portal is **cryptographic, just-in-time permission for one
33
+ actor to reach one private resource** — without turning that resource into
34
+ public inventory:
35
+
36
+ - **Time-limited** — portals expire after minutes, hours, or days
17
37
  - **IP-scoped** — access is granted only to the requesting IP via NHP
18
38
  - **Auditable** — every access is logged with who, when, and from where
19
39
  - **Revocable** — kill access instantly if something goes wrong
@@ -30,30 +50,125 @@ For LangChain integration:
30
50
  pip install qurl-python[langchain]
31
51
  ```
32
52
 
33
- ## Quick Start
53
+ ## Quickstart
34
54
 
35
55
  ```python
36
56
  from layerv_qurl import QURLClient
37
57
 
38
58
  client = QURLClient(api_key="lv_live_xxx")
39
59
 
40
- # Create a protected link
60
+ resource = client.protect_url("https://internal.example.com/dashboard")
61
+ portal = resource.create_portal(valid_for="5m")
62
+
63
+ print(portal.link) # Share this link — recipients need no credentials
64
+ ```
65
+
66
+ That is the core flow:
67
+
68
+ | Step | Call | What you provide |
69
+ | --- | --- | --- |
70
+ | Protect a private URL | `client.protect_url` | The target URL you already know |
71
+ | Mint a short-lived access link | `resource.create_portal` | The returned resource handle |
72
+
73
+ `protect_url` is idempotent for the same account and target URL: protecting
74
+ the same URL again returns the existing resource. `valid_for` accepts a
75
+ duration string (`"5m"`, `"24h"`) or a `datetime.timedelta`; prefer short
76
+ portal lifetimes.
77
+
78
+ If qURL Connector already protects the service, use the connector id instead
79
+ of calling `protect_url`:
80
+
81
+ ```python
82
+ resource = client.connector_resource("prod-dashboard")
83
+ portal = resource.create_portal(valid_for="5m")
84
+ ```
85
+
86
+ If you persist the resource id, future calls do not need to recreate the
87
+ handle (no API call is made until you mint):
88
+
89
+ ```python
90
+ resource = client.resource_by_id("r_demo1234567")
91
+ portal = resource.create_portal(valid_for="1h")
92
+ ```
93
+
94
+ For one-off scripts, `client.create_portal_for_url` combines the two API calls
95
+ and returns both the portal and a reusable resource handle:
96
+
97
+ ```python
98
+ portal, resource = client.create_portal_for_url(
99
+ "https://internal.example.com/dashboard", valid_for="5m"
100
+ )
101
+ ```
102
+
103
+ Portal options mirror qurl-go:
104
+
105
+ ```python
106
+ from datetime import timedelta
107
+
108
+ portal = resource.create_portal(
109
+ valid_for=timedelta(minutes=5),
110
+ label="Alice from Acme",
111
+ one_time_use=True,
112
+ max_sessions=1,
113
+ )
114
+ ```
115
+
116
+ ## Opening Portals
117
+
118
+ Most recipients open qURL links directly and do not use this SDK at all. If
119
+ you are building a service or agent that opens received qURL links
120
+ programmatically, `enter_portal` accepts a full link or a bare access token,
121
+ grants network access for the caller's IP, and returns the reachable resource:
122
+
123
+ ```python
124
+ handle = client.enter_portal(link)
125
+ print(handle.resource_url) # The reachable resource location
126
+ print(handle.open_seconds) # How long access stays open
127
+ ```
128
+
129
+ Unlike qurl-go's offline `EnterPortal`, this SDK opens links through the
130
+ LayerV API: the client needs an API key with the `qurl:resolve` scope.
131
+ `enter_portal` fails closed — if access is granted but no resource URL comes
132
+ back, it raises instead of returning an empty handle.
133
+
134
+ ## Async Usage
135
+
136
+ ```python
137
+ import asyncio
138
+ from layerv_qurl import AsyncQURLClient
139
+
140
+ async def main():
141
+ async with AsyncQURLClient(api_key="lv_live_xxx") as client:
142
+ resource = await client.protect_url("https://internal.example.com/dashboard")
143
+ portal = await resource.create_portal(valid_for="5m")
144
+ print(portal.link)
145
+
146
+ asyncio.run(main())
147
+ ```
148
+
149
+ ## REST-Shaped API (Compatibility)
150
+
151
+ The original REST-shaped methods remain fully supported and share the same
152
+ client. Use them for the qURL/resource/token management surface that has no
153
+ portal-verb equivalent (listing, updating, revoking, quotas, webhooks, ...) or
154
+ if you already build on them:
155
+
156
+ ```python
157
+ # Create a protected link (portal equivalent: create_portal_for_url)
41
158
  result = client.create(
42
159
  target_url="https://api.example.com/data",
43
160
  expires_in="24h",
44
161
  label="API access for agent",
45
162
  )
46
- print(result.qurl_link) # Share this link
163
+ print(result.qurl_link)
47
164
 
48
- # Resolve a token (grants network access for your IP)
165
+ # Resolve a token headlessly (portal equivalent: enter_portal)
49
166
  access = client.resolve("at_k8xqp9h2sj9lx7r4a")
50
167
  print(f"Access granted to {access.target_url} for {access.access_grant.expires_in}s")
51
168
 
52
- # Extend a qURL's expiration
169
+ # Extend a qURL's expiration and update metadata
53
170
  qurl = client.extend("r_xxx", "7d")
54
-
55
- # Update resource metadata
56
- qurl = client.update("r_xxx", description="extended", extend_by="7d")
171
+ qurl = client.update("r_xxx", description="extended")
57
172
  ```
58
173
 
59
174
  ## Authentication Notes
@@ -81,23 +196,6 @@ Fields such as webhook `events` and API-key `scopes` accept ordered non-string
81
196
  iterables of strings. Lists, tuples, and generators preserve the caller's
82
197
  iteration order; sets are rejected because their iteration order is not stable.
83
198
 
84
- ## Async Usage
85
-
86
- ```python
87
- import asyncio
88
- from layerv_qurl import AsyncQURLClient
89
-
90
- async def main():
91
- async with AsyncQURLClient(api_key="lv_live_xxx") as client:
92
- result = await client.create(target_url="https://example.com", expires_in="1h")
93
- access = await client.resolve("at_...")
94
-
95
- # Extend expiration
96
- qurl = await client.extend("r_xxx", "7d")
97
-
98
- asyncio.run(main())
99
- ```
100
-
101
199
  ## Pagination
102
200
 
103
201
  ```python
@@ -170,13 +268,13 @@ from layerv_qurl.errors import (
170
268
  client = QURLClient(api_key="lv_live_xxx")
171
269
 
172
270
  try:
173
- client.resolve("at_k8xqp9h2sj9lx7r4a")
271
+ client.enter_portal("https://qurl.link/#at_k8xqp9h2sj9lx7r4a")
174
272
  except AuthenticationError:
175
273
  print("Bad API key")
176
274
  except AuthorizationError:
177
275
  print("Valid key but missing qurl:resolve scope")
178
276
  except NotFoundError:
179
- print("Token doesn't exist or already expired")
277
+ print("Portal doesn't exist or already expired")
180
278
  except RateLimitError as e:
181
279
  print(f"Rate limited — retry in {e.retry_after}s")
182
280
  except ValidationError as e:
@@ -244,6 +342,16 @@ tools = toolkit.get_tools() # [CreateQURLTool, ResolveQURLTool, ListQURLsTool,
244
342
  | `user_agent` | No | `qurl-python-sdk/<version>` |
245
343
  | `http_client` | No | Auto-created `httpx.Client` |
246
344
 
345
+ ## Security Notes
346
+
347
+ - Treat API keys and qURL links like credentials. Do not log them.
348
+ - Prefer short portal lifetimes such as `valid_for="5m"`.
349
+ - Do not ask portal recipients to handle credentials. Recipients only need
350
+ the link.
351
+ - `protect_url` and `create_portal_for_url` reject malformed target URLs and
352
+ URLs with embedded credentials (`https://user:pass@...`) before any request,
353
+ matching qurl-go.
354
+
247
355
  ## License
248
356
 
249
357
  MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "qurl-python"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Python SDK for the qURL™ API — secure, time-limited access links. Quantum URL is how you enter the hidden layer of the internet."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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,
@@ -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",