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.
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/ci.yml +2 -2
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/dependency-age-check-actions.yml +1 -1
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/issue-priority.yml +1 -1
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/pr-title.yml +1 -1
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/release-please.yml +2 -2
- qurl_python-0.2.2/.release-please-manifest.json +1 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/CHANGELOG.md +7 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/PKG-INFO +140 -32
- {qurl_python-0.2.1 → qurl_python-0.2.2}/README.md +139 -31
- {qurl_python-0.2.1 → qurl_python-0.2.2}/pyproject.toml +1 -1
- {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/__init__.py +15 -3
- {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/_utils.py +308 -2
- {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/async_client.py +244 -1
- {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/client.py +362 -11
- {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/types.py +34 -0
- qurl_python-0.2.2/tests/test_portal.py +733 -0
- qurl_python-0.2.1/.release-please-manifest.json +0 -1
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/dependabot.yml +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/claude-code-review.yml +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/claude.yml +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/codeql.yml +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/dependency-age-check-pip.yml +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/dependency-review.yml +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/secrets-scan.yml +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.github/workflows/validate-issue-templates.yml +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/.gitignore +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/CLAUDE.md +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/CONTRIBUTING.md +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/LICENSE +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/SECURITY.md +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/release-please-config.json +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/errors.py +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/langchain.py +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/src/layerv_qurl/py.typed +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/tests/__init__.py +0 -0
- {qurl_python-0.2.1 → qurl_python-0.2.2}/tests/test_client.py +0 -0
- {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@
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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.
|
|
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
|
[](https://pypi.org/project/qurl-python/)
|
|
39
39
|
[](LICENSE)
|
|
40
40
|
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
+
## Why qURL?
|
|
48
54
|
|
|
49
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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)
|
|
196
|
+
print(result.qurl_link)
|
|
80
197
|
|
|
81
|
-
# Resolve a token (
|
|
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.
|
|
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("
|
|
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
|
[](https://pypi.org/project/qurl-python/)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
+
## Why qURL?
|
|
15
21
|
|
|
16
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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)
|
|
163
|
+
print(result.qurl_link)
|
|
47
164
|
|
|
48
|
-
# Resolve a token (
|
|
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.
|
|
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("
|
|
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.
|
|
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 —
|
|
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",
|