parad 2.2.4__tar.gz → 2.2.6__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.
- {parad-2.2.4 → parad-2.2.6}/PKG-INFO +23 -10
- {parad-2.2.4 → parad-2.2.6}/README.md +22 -9
- {parad-2.2.4 → parad-2.2.6}/parad/__init__.py +3 -3
- parad-2.2.6/parad/commands/auth.py +110 -0
- {parad-2.2.4 → parad-2.2.6}/parad/commands/connect.py +8 -9
- {parad-2.2.4 → parad-2.2.6}/parad/commands/init.py +8 -9
- parad-2.2.6/parad/commands/url.py +62 -0
- {parad-2.2.4 → parad-2.2.6}/parad/config.py +105 -0
- {parad-2.2.4 → parad-2.2.6}/parad/connection.py +36 -41
- {parad-2.2.4 → parad-2.2.6}/parad/gateway.py +28 -21
- {parad-2.2.4 → parad-2.2.6}/parad.egg-info/PKG-INFO +23 -10
- {parad-2.2.4 → parad-2.2.6}/pyproject.toml +1 -1
- {parad-2.2.4 → parad-2.2.6}/tests/test_connection.py +85 -41
- {parad-2.2.4 → parad-2.2.6}/tests/test_workflow.py +10 -20
- parad-2.2.4/parad/commands/auth.py +0 -109
- parad-2.2.4/parad/commands/url.py +0 -35
- {parad-2.2.4 → parad-2.2.6}/parad/__main__.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/cli.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/commands/__init__.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/commands/backups.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/commands/config_cmd.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/commands/databases.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/commands/projects.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/commands/query.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/commands/shell.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/commands/status.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/commands/sync.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/commands/versions.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/commands/watch.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/crypto.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/dbapi.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/engine.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/sqlalchemy.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/state.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/types.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad/watcher.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad.egg-info/SOURCES.txt +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad.egg-info/dependency_links.txt +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad.egg-info/entry_points.txt +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad.egg-info/requires.txt +0 -0
- {parad-2.2.4 → parad-2.2.6}/parad.egg-info/top_level.txt +0 -0
- {parad-2.2.4 → parad-2.2.6}/setup.cfg +0 -0
- {parad-2.2.4 → parad-2.2.6}/tests/test_crypto.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/tests/test_engine.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/tests/test_sqlalchemy.py +0 -0
- {parad-2.2.4 → parad-2.2.6}/tests/test_state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: parad
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.6
|
|
4
4
|
Summary: Drop-in encrypted database with cloud sync — connect() and go
|
|
5
5
|
Author-email: nexuss0781 <nexuss0781@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/nexuss0781/Paradox-DB
|
|
@@ -46,9 +46,9 @@ sync daemon version-stores your changes automatically. No manual push needed.
|
|
|
46
46
|
```python
|
|
47
47
|
from parad import connect
|
|
48
48
|
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
db = connect(url="parad://
|
|
49
|
+
# Exchange a Nexuss API key once for a Paradox key, then use the Paradox key
|
|
50
|
+
# in the connection URL to auto-provision the project and database.
|
|
51
|
+
db = connect(url="parad://pk_example@local/myproj/mydb?passphrase=secret")
|
|
52
52
|
|
|
53
53
|
# Build SQL offline like any SQLite database ...
|
|
54
54
|
db.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
|
|
@@ -76,9 +76,10 @@ db = connect("mydb", passphrase="secret", auto_sync=False)
|
|
|
76
76
|
|
|
77
77
|
```bash
|
|
78
78
|
# Create a new encrypted database and print the canonical URL deliberately
|
|
79
|
-
parad auth login
|
|
79
|
+
parad auth login --api-key "$PARADOX_API_KEY"
|
|
80
80
|
parad init mydb --project myproject --print-database-url
|
|
81
|
-
# Retrieve the existing canonical URL
|
|
81
|
+
# Retrieve the existing canonical URL; local values are checked first,
|
|
82
|
+
# then the owner-authenticated gateway recovery endpoint is used
|
|
82
83
|
parad url
|
|
83
84
|
parad url --print-database-url
|
|
84
85
|
# Push to cloud
|
|
@@ -113,30 +114,40 @@ parad shell
|
|
|
113
114
|
| `parad delete <table> <where>` | Delete rows |
|
|
114
115
|
| `parad shell` | Interactive SQL REPL |
|
|
115
116
|
| `parad config show/set` | Manage config; secret-bearing fields are redacted |
|
|
116
|
-
| `parad url [name]` | Retrieve the canonical database_url |
|
|
117
|
+
| `parad url [name]` | Retrieve the canonical database_url using local values, owner-authenticated server recovery, then legacy fallback |
|
|
118
|
+
| `parad url register <url>` | Explicitly store a known canonical URL on the gateway without snapshot mutation |
|
|
117
119
|
| `parad database-url [name]` | Alias for `parad url` |
|
|
118
120
|
## Canonical DATABASE_URL first
|
|
119
121
|
Use one canonical connection value for new applications and deployments. After `parad init` provisions the project/database, it persists `database_url` in `~/.paradox/config.json` and prints a redacted URL by default:
|
|
120
122
|
```bash
|
|
121
|
-
parad auth login
|
|
123
|
+
parad auth login --api-key "$PARADOX_API_KEY"
|
|
122
124
|
parad init mydb --project myproject
|
|
123
125
|
```
|
|
124
126
|
Print the complete secret-bearing value only when intentionally copying it into a secret manager:
|
|
125
127
|
```bash
|
|
126
128
|
parad init mydb --project myproject --print-database-url
|
|
127
129
|
```
|
|
128
|
-
For an existing database, retrieve the saved canonical value
|
|
130
|
+
For an existing database, retrieve the saved canonical value. If local values are unavailable, the CLI resolves the owned project/database and calls the read-only owner-authenticated gateway recovery endpoint:
|
|
129
131
|
```bash
|
|
130
132
|
parad url
|
|
131
133
|
parad url --print-database-url
|
|
132
134
|
```
|
|
133
|
-
Retrieval checks `DATABASE_URL`, then persisted `database_url`, then reconstructs and persists a canonical URL from legacy split fields when a passphrase is available. If the passphrase is missing, it stops instead of inventing a replacement. New projects should use only `DATABASE_URL`; split fields remain supported for legacy applications.
|
|
135
|
+
Retrieval checks `DATABASE_URL`, then persisted `database_url`, then server recovery, then reconstructs and persists a canonical URL from legacy split fields when a passphrase is available. Project-scoped connections register the canonical URL with the gateway, which stores it encrypted at rest. The full value is returned only with `--print-database-url`; ordinary output is redacted. If the server has no stored URL and the passphrase is missing, it stops instead of inventing a replacement. New projects should use only `DATABASE_URL`; split fields remain supported for legacy applications.
|
|
136
|
+
|
|
137
|
+
For a database created before server URL storage existed, provide the already-known URL once with `parad url register '<url>'`. This updates only the encrypted server field; it does not run `init`, push, pull, or overwrite snapshots. A URL that was never previously stored cannot be recovered from server metadata alone.
|
|
134
138
|
Applications can use the same single value:
|
|
135
139
|
```python
|
|
136
140
|
import os
|
|
137
141
|
from parad import connect
|
|
138
142
|
db = connect(url=os.environ["DATABASE_URL"])
|
|
139
143
|
```
|
|
144
|
+
|
|
145
|
+
For a pre-feature database whose canonical URL is already known, register it explicitly without opening or syncing the database:
|
|
146
|
+
```python
|
|
147
|
+
from parad import register_canonical_database_url
|
|
148
|
+
register_canonical_database_url(os.environ["DATABASE_URL"])
|
|
149
|
+
```
|
|
150
|
+
|
|
140
151
|
An explicit `url` argument is strongest. Explicit `name` or `db_path` options remain available for legacy target selection.
|
|
141
152
|
|
|
142
153
|
|
|
@@ -161,6 +172,8 @@ Config lives at `~/.paradox/config.json`:
|
|
|
161
172
|
|
|
162
173
|
## Security
|
|
163
174
|
|
|
175
|
+
The gateway stores the canonical `database_url` encrypted at rest in `paradox_dbs.database_url_encrypted`. Set a stable `DATABASE_URL_ENCRYPTION_KEY` on the gateway. The redacted endpoint is safe for metadata checks; full recovery requires the owner API key and an explicit reveal command.
|
|
176
|
+
|
|
164
177
|
- AES-256-CBC encryption at rest
|
|
165
178
|
- PBKDF2-HMAC-SHA512 key derivation (256k iterations)
|
|
166
179
|
- Your passphrase never leaves your machine
|
|
@@ -20,9 +20,9 @@ sync daemon version-stores your changes automatically. No manual push needed.
|
|
|
20
20
|
```python
|
|
21
21
|
from parad import connect
|
|
22
22
|
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
db = connect(url="parad://
|
|
23
|
+
# Exchange a Nexuss API key once for a Paradox key, then use the Paradox key
|
|
24
|
+
# in the connection URL to auto-provision the project and database.
|
|
25
|
+
db = connect(url="parad://pk_example@local/myproj/mydb?passphrase=secret")
|
|
26
26
|
|
|
27
27
|
# Build SQL offline like any SQLite database ...
|
|
28
28
|
db.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
|
|
@@ -50,9 +50,10 @@ db = connect("mydb", passphrase="secret", auto_sync=False)
|
|
|
50
50
|
|
|
51
51
|
```bash
|
|
52
52
|
# Create a new encrypted database and print the canonical URL deliberately
|
|
53
|
-
parad auth login
|
|
53
|
+
parad auth login --api-key "$PARADOX_API_KEY"
|
|
54
54
|
parad init mydb --project myproject --print-database-url
|
|
55
|
-
# Retrieve the existing canonical URL
|
|
55
|
+
# Retrieve the existing canonical URL; local values are checked first,
|
|
56
|
+
# then the owner-authenticated gateway recovery endpoint is used
|
|
56
57
|
parad url
|
|
57
58
|
parad url --print-database-url
|
|
58
59
|
# Push to cloud
|
|
@@ -87,30 +88,40 @@ parad shell
|
|
|
87
88
|
| `parad delete <table> <where>` | Delete rows |
|
|
88
89
|
| `parad shell` | Interactive SQL REPL |
|
|
89
90
|
| `parad config show/set` | Manage config; secret-bearing fields are redacted |
|
|
90
|
-
| `parad url [name]` | Retrieve the canonical database_url |
|
|
91
|
+
| `parad url [name]` | Retrieve the canonical database_url using local values, owner-authenticated server recovery, then legacy fallback |
|
|
92
|
+
| `parad url register <url>` | Explicitly store a known canonical URL on the gateway without snapshot mutation |
|
|
91
93
|
| `parad database-url [name]` | Alias for `parad url` |
|
|
92
94
|
## Canonical DATABASE_URL first
|
|
93
95
|
Use one canonical connection value for new applications and deployments. After `parad init` provisions the project/database, it persists `database_url` in `~/.paradox/config.json` and prints a redacted URL by default:
|
|
94
96
|
```bash
|
|
95
|
-
parad auth login
|
|
97
|
+
parad auth login --api-key "$PARADOX_API_KEY"
|
|
96
98
|
parad init mydb --project myproject
|
|
97
99
|
```
|
|
98
100
|
Print the complete secret-bearing value only when intentionally copying it into a secret manager:
|
|
99
101
|
```bash
|
|
100
102
|
parad init mydb --project myproject --print-database-url
|
|
101
103
|
```
|
|
102
|
-
For an existing database, retrieve the saved canonical value
|
|
104
|
+
For an existing database, retrieve the saved canonical value. If local values are unavailable, the CLI resolves the owned project/database and calls the read-only owner-authenticated gateway recovery endpoint:
|
|
103
105
|
```bash
|
|
104
106
|
parad url
|
|
105
107
|
parad url --print-database-url
|
|
106
108
|
```
|
|
107
|
-
Retrieval checks `DATABASE_URL`, then persisted `database_url`, then reconstructs and persists a canonical URL from legacy split fields when a passphrase is available. If the passphrase is missing, it stops instead of inventing a replacement. New projects should use only `DATABASE_URL`; split fields remain supported for legacy applications.
|
|
109
|
+
Retrieval checks `DATABASE_URL`, then persisted `database_url`, then server recovery, then reconstructs and persists a canonical URL from legacy split fields when a passphrase is available. Project-scoped connections register the canonical URL with the gateway, which stores it encrypted at rest. The full value is returned only with `--print-database-url`; ordinary output is redacted. If the server has no stored URL and the passphrase is missing, it stops instead of inventing a replacement. New projects should use only `DATABASE_URL`; split fields remain supported for legacy applications.
|
|
110
|
+
|
|
111
|
+
For a database created before server URL storage existed, provide the already-known URL once with `parad url register '<url>'`. This updates only the encrypted server field; it does not run `init`, push, pull, or overwrite snapshots. A URL that was never previously stored cannot be recovered from server metadata alone.
|
|
108
112
|
Applications can use the same single value:
|
|
109
113
|
```python
|
|
110
114
|
import os
|
|
111
115
|
from parad import connect
|
|
112
116
|
db = connect(url=os.environ["DATABASE_URL"])
|
|
113
117
|
```
|
|
118
|
+
|
|
119
|
+
For a pre-feature database whose canonical URL is already known, register it explicitly without opening or syncing the database:
|
|
120
|
+
```python
|
|
121
|
+
from parad import register_canonical_database_url
|
|
122
|
+
register_canonical_database_url(os.environ["DATABASE_URL"])
|
|
123
|
+
```
|
|
124
|
+
|
|
114
125
|
An explicit `url` argument is strongest. Explicit `name` or `db_path` options remain available for legacy target selection.
|
|
115
126
|
|
|
116
127
|
|
|
@@ -135,6 +146,8 @@ Config lives at `~/.paradox/config.json`:
|
|
|
135
146
|
|
|
136
147
|
## Security
|
|
137
148
|
|
|
149
|
+
The gateway stores the canonical `database_url` encrypted at rest in `paradox_dbs.database_url_encrypted`. Set a stable `DATABASE_URL_ENCRYPTION_KEY` on the gateway. The redacted endpoint is safe for metadata checks; full recovery requires the owner API key and an explicit reveal command.
|
|
150
|
+
|
|
138
151
|
- AES-256-CBC encryption at rest
|
|
139
152
|
- PBKDF2-HMAC-SHA512 key derivation (256k iterations)
|
|
140
153
|
- Your passphrase never leaves your machine
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from parad.connection import connect, ParadConnection, parse_url, generate_url, redact_url, db_state_key, generate_passphrase
|
|
4
4
|
from parad.engine import Engine
|
|
5
|
-
from parad.config import load_config, get_passphrase, get_canonical_database_url, get_connection_url
|
|
5
|
+
from parad.config import load_config, get_passphrase, get_canonical_database_url, recover_canonical_database_url, register_canonical_database_url, get_connection_url
|
|
6
6
|
|
|
7
|
-
__version__ = "2.2.
|
|
8
|
-
__all__ = ["connect", "ParadConnection", "parse_url", "generate_url", "redact_url", "db_state_key", "generate_passphrase", "Engine", "load_config", "get_canonical_database_url", "get_connection_url"]
|
|
7
|
+
__version__ = "2.2.6"
|
|
8
|
+
__all__ = ["connect", "ParadConnection", "parse_url", "generate_url", "redact_url", "db_state_key", "generate_passphrase", "Engine", "load_config", "get_passphrase", "get_canonical_database_url", "recover_canonical_database_url", "register_canonical_database_url", "get_connection_url"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""parad auth — authenticate with Paradox or Nexuss Auth API keys."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from click import ClickException
|
|
5
|
+
from parad.config import load_config, save_config, set_config_value
|
|
6
|
+
from parad.gateway import GatewayClient, GatewayError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group("auth")
|
|
10
|
+
def auth_group():
|
|
11
|
+
"""Manage authentication."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def authenticate_api_key(config, api_key: str) -> tuple[GatewayClient, dict]:
|
|
16
|
+
"""Validate a Paradox key or exchange a Nexuss key without persisting nxa_."""
|
|
17
|
+
supplied = api_key.strip()
|
|
18
|
+
if not supplied:
|
|
19
|
+
raise ClickException("Provide a Paradox pk_ key or a Nexuss Auth nxa_ key")
|
|
20
|
+
gateway = GatewayClient(config.sync.gateway_url)
|
|
21
|
+
if supplied.startswith("nxa_"):
|
|
22
|
+
result = gateway.exchange_nexuss_api_key(supplied)
|
|
23
|
+
resolved_key = result.get("api_key", "")
|
|
24
|
+
if not resolved_key.startswith("pk_"):
|
|
25
|
+
raise ClickException("Nexuss Auth exchange did not return a Paradox API key")
|
|
26
|
+
gateway.api_key = resolved_key
|
|
27
|
+
else:
|
|
28
|
+
gateway.api_key = supplied
|
|
29
|
+
result = {"api_key": supplied, **gateway.get_me()}
|
|
30
|
+
set_config_value("sync.api_key", gateway.api_key)
|
|
31
|
+
config.sync.api_key = gateway.api_key
|
|
32
|
+
return gateway, result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@auth_group.command("register")
|
|
36
|
+
def register():
|
|
37
|
+
"""Explain the passwordless Nexuss Auth registration flow."""
|
|
38
|
+
raise ClickException(
|
|
39
|
+
"Paradox accounts are created through Nexuss Auth. Sign in with Google in the Paradox web flow, "
|
|
40
|
+
"then run 'parad auth login --api-key <nexuss nxa_ key>'."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@auth_group.command("login")
|
|
45
|
+
@click.option("--api-key", envvar="PARADOX_API_KEY", help="Paradox pk_ key or Nexuss Auth nxa_ key")
|
|
46
|
+
def login(api_key: str | None):
|
|
47
|
+
"""Authenticate using a Paradox key or exchange a Nexuss Auth key."""
|
|
48
|
+
config = load_config()
|
|
49
|
+
supplied = api_key or click.prompt("Paradox or Nexuss API key", hide_input=True)
|
|
50
|
+
try:
|
|
51
|
+
_, result = authenticate_api_key(config, supplied)
|
|
52
|
+
click.echo(f"✓ Logged in as {result.get('username', '')}")
|
|
53
|
+
except GatewayError as e:
|
|
54
|
+
click.echo(f"✗ Login failed: {e}")
|
|
55
|
+
raise SystemExit(1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _ensure_auth(gw):
|
|
59
|
+
"""Ensure user is authenticated. Prompt for an API key if needed.
|
|
60
|
+
|
|
61
|
+
In non-interactive mode (CI/cloud), raises an error instead of prompting.
|
|
62
|
+
"""
|
|
63
|
+
if gw.api_key:
|
|
64
|
+
try:
|
|
65
|
+
gw.get_me()
|
|
66
|
+
return
|
|
67
|
+
except GatewayError:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
# Non-interactive mode: raise clear error
|
|
71
|
+
import sys
|
|
72
|
+
if not sys.stdin.isatty():
|
|
73
|
+
raise ClickException(
|
|
74
|
+
"Not authenticated. Set PARADOX_API_KEY environment variable or run 'parad auth login' first."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
click.echo("Authentication required.")
|
|
78
|
+
api_key = click.prompt("Paradox or Nexuss API key", hide_input=True)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
config = load_config()
|
|
82
|
+
resolved, _ = authenticate_api_key(config, api_key)
|
|
83
|
+
gw.api_key = resolved.api_key
|
|
84
|
+
if gw.api_key:
|
|
85
|
+
click.echo("✓ Authentication successful")
|
|
86
|
+
else:
|
|
87
|
+
raise ClickException("Authentication succeeded but no Paradox API key was received")
|
|
88
|
+
except GatewayError as e:
|
|
89
|
+
raise ClickException(f"Authentication failed: {e}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _require_auth(gw):
|
|
93
|
+
"""Require authentication. Raises ClickException if auth fails."""
|
|
94
|
+
_ensure_auth(gw)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@auth_group.command("status")
|
|
98
|
+
def auth_status():
|
|
99
|
+
"""Show current authentication status."""
|
|
100
|
+
config = load_config()
|
|
101
|
+
if not config.sync.api_key:
|
|
102
|
+
click.echo("Not logged in.")
|
|
103
|
+
return
|
|
104
|
+
gw = GatewayClient(config.sync.gateway_url, config.sync.api_key)
|
|
105
|
+
try:
|
|
106
|
+
me = gw.get_me()
|
|
107
|
+
click.echo(f"Logged in as: {me.get('username', 'unknown')} ({me.get('email', '')})")
|
|
108
|
+
click.echo(f"User ID: {me.get('id', 'unknown')}")
|
|
109
|
+
except GatewayError:
|
|
110
|
+
click.echo("API key invalid. Run: parad auth login")
|
|
@@ -6,12 +6,13 @@ from parad.config import load_config, save_config, config_dir, gateway_db_name,
|
|
|
6
6
|
from parad.connection import db_state_key
|
|
7
7
|
from parad.engine import Engine
|
|
8
8
|
from parad.gateway import GatewayClient, GatewayError
|
|
9
|
+
from parad.commands.auth import authenticate_api_key
|
|
9
10
|
from parad.state import get_remote_version, set_remote_version, set_last_local_hash
|
|
10
11
|
from parad.watcher import is_running, get_daemon_pid, start_daemon
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def _ensure_auth(config):
|
|
14
|
-
"""Auto-authenticate: prompt for
|
|
15
|
+
"""Auto-authenticate: prompt for an API key if no valid token.
|
|
15
16
|
|
|
16
17
|
In non-interactive mode (CI/cloud), raises an error instead of prompting.
|
|
17
18
|
"""
|
|
@@ -32,18 +33,16 @@ def _ensure_auth(config):
|
|
|
32
33
|
)
|
|
33
34
|
|
|
34
35
|
click.echo("Authentication required.")
|
|
35
|
-
|
|
36
|
-
password = click.prompt("Password", hide_input=True)
|
|
36
|
+
api_key = click.prompt("Paradox or Nexuss API key", hide_input=True)
|
|
37
37
|
|
|
38
38
|
try:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if
|
|
42
|
-
|
|
43
|
-
config.sync.api_key = token
|
|
39
|
+
resolved, _ = authenticate_api_key(config, api_key)
|
|
40
|
+
gw.api_key = resolved.api_key
|
|
41
|
+
if gw.api_key:
|
|
42
|
+
config.sync.api_key = gw.api_key
|
|
44
43
|
click.echo("✓ Authentication successful")
|
|
45
44
|
else:
|
|
46
|
-
raise click.ClickException("
|
|
45
|
+
raise click.ClickException("Authentication succeeded but no Paradox API key was received")
|
|
47
46
|
except GatewayError as e:
|
|
48
47
|
raise click.ClickException(f"Authentication failed: {e}")
|
|
49
48
|
|
|
@@ -6,12 +6,13 @@ from parad.config import load_config, save_config, config_dir, gateway_db_name,
|
|
|
6
6
|
from parad.connection import db_state_key, generate_url, redact_url
|
|
7
7
|
from parad.engine import Engine
|
|
8
8
|
from parad.gateway import GatewayClient, GatewayError
|
|
9
|
+
from parad.commands.auth import authenticate_api_key
|
|
9
10
|
from parad.state import set_remote_version, set_last_local_hash
|
|
10
11
|
from parad.watcher import is_running
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def _ensure_auth(config):
|
|
14
|
-
"""Auto-authenticate: prompt for
|
|
15
|
+
"""Auto-authenticate: prompt for an API key if no valid token.
|
|
15
16
|
|
|
16
17
|
In non-interactive mode (CI/cloud), raises an error instead of prompting.
|
|
17
18
|
"""
|
|
@@ -32,18 +33,16 @@ def _ensure_auth(config):
|
|
|
32
33
|
)
|
|
33
34
|
|
|
34
35
|
click.echo("Authentication required.")
|
|
35
|
-
|
|
36
|
-
password = click.prompt("Password", hide_input=True)
|
|
36
|
+
api_key = click.prompt("Paradox or Nexuss API key", hide_input=True)
|
|
37
37
|
|
|
38
38
|
try:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if
|
|
42
|
-
|
|
43
|
-
config.sync.api_key = token
|
|
39
|
+
resolved, _ = authenticate_api_key(config, api_key)
|
|
40
|
+
gw.api_key = resolved.api_key
|
|
41
|
+
if gw.api_key:
|
|
42
|
+
config.sync.api_key = gw.api_key
|
|
44
43
|
click.echo("✓ Authentication successful")
|
|
45
44
|
else:
|
|
46
|
-
raise click.ClickException("
|
|
45
|
+
raise click.ClickException("Authentication succeeded but no Paradox API key was received")
|
|
47
46
|
except GatewayError as e:
|
|
48
47
|
raise click.ClickException(f"Authentication failed: {e}")
|
|
49
48
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Canonical database URL retrieval commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from parad.config import recover_canonical_database_url, register_canonical_database_url
|
|
8
|
+
from parad.connection import redact_url
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _emit_url(name: str | None, print_database_url: bool, json_mode: bool) -> None:
|
|
12
|
+
database_url = recover_canonical_database_url(name)
|
|
13
|
+
displayed = database_url if print_database_url else redact_url(database_url)
|
|
14
|
+
if json_mode:
|
|
15
|
+
click.echo(json.dumps({"database_url": displayed}))
|
|
16
|
+
else:
|
|
17
|
+
click.echo(f"DATABASE_URL: {displayed}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _register_url(database_url: str, print_database_url: bool, json_mode: bool) -> None:
|
|
21
|
+
registered = register_canonical_database_url(database_url)
|
|
22
|
+
displayed = registered if print_database_url else redact_url(registered)
|
|
23
|
+
if json_mode:
|
|
24
|
+
click.echo(json.dumps({"registered": True, "database_url": displayed}))
|
|
25
|
+
else:
|
|
26
|
+
click.echo(f"Registered DATABASE_URL: {displayed}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _handle_url_command(
|
|
30
|
+
name: str | None,
|
|
31
|
+
database_url: str | None,
|
|
32
|
+
print_database_url: bool,
|
|
33
|
+
json_mode: bool,
|
|
34
|
+
) -> None:
|
|
35
|
+
if name == "register":
|
|
36
|
+
if not database_url:
|
|
37
|
+
raise click.UsageError("Usage: parad url register <canonical-url>")
|
|
38
|
+
_register_url(database_url, print_database_url, json_mode)
|
|
39
|
+
return
|
|
40
|
+
if database_url:
|
|
41
|
+
raise click.UsageError("Only `register` accepts a second argument")
|
|
42
|
+
_emit_url(name, print_database_url, json_mode)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@click.command("url")
|
|
46
|
+
@click.argument("name", required=False)
|
|
47
|
+
@click.argument("database_url", required=False)
|
|
48
|
+
@click.option("--print-database-url", is_flag=True, help="Print the full secret-bearing DATABASE_URL")
|
|
49
|
+
@click.option("--json", "json_mode", is_flag=True, help="Print JSON output")
|
|
50
|
+
def url_command(name: str | None, database_url: str | None, print_database_url: bool, json_mode: bool) -> None:
|
|
51
|
+
"""Retrieve or explicitly register the canonical database_url."""
|
|
52
|
+
_handle_url_command(name, database_url, print_database_url, json_mode)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@click.command("database-url")
|
|
56
|
+
@click.argument("name", required=False)
|
|
57
|
+
@click.argument("database_url", required=False)
|
|
58
|
+
@click.option("--print-database-url", is_flag=True, help="Print the full secret-bearing DATABASE_URL")
|
|
59
|
+
@click.option("--json", "json_mode", is_flag=True, help="Print JSON output")
|
|
60
|
+
def database_url_command(name: str | None, database_url: str | None, print_database_url: bool, json_mode: bool) -> None:
|
|
61
|
+
"""Alias for ``parad url``."""
|
|
62
|
+
_handle_url_command(name, database_url, print_database_url, json_mode)
|
|
@@ -199,6 +199,111 @@ def get_canonical_database_url(name: str | None = None) -> str:
|
|
|
199
199
|
return canonical
|
|
200
200
|
|
|
201
201
|
|
|
202
|
+
def recover_canonical_database_url(name: str | None = None) -> str:
|
|
203
|
+
"""Recover database_url from the server, then fall back locally.
|
|
204
|
+
|
|
205
|
+
The gateway lookup is read-only and requires the configured API key. The
|
|
206
|
+
gateway returns the URL only through the explicit owner-authenticated
|
|
207
|
+
reveal endpoint; a successful result is persisted locally.
|
|
208
|
+
"""
|
|
209
|
+
config = load_config()
|
|
210
|
+
configured = os.environ.get("DATABASE_URL", "").strip() or config.database_url.strip()
|
|
211
|
+
if configured:
|
|
212
|
+
from parad.connection import parse_url
|
|
213
|
+
|
|
214
|
+
parsed = parse_url(configured)
|
|
215
|
+
if name and parsed["name"] != name:
|
|
216
|
+
raise ValueError(
|
|
217
|
+
f"Canonical DATABASE_URL points to '{parsed['name']}', not '{name}'"
|
|
218
|
+
)
|
|
219
|
+
return configured
|
|
220
|
+
|
|
221
|
+
inferred_name = name or gateway_db_name(config.database_path)
|
|
222
|
+
gateway_url = os.environ.get("PARADOX_GATEWAY", "").strip() or config.sync.gateway_url.strip()
|
|
223
|
+
api_key = os.environ.get("PARADOX_API_KEY", "").strip() or config.sync.api_key.strip()
|
|
224
|
+
if gateway_url and api_key:
|
|
225
|
+
from parad.gateway import GatewayClient, GatewayError
|
|
226
|
+
|
|
227
|
+
gateway = GatewayClient(gateway_url, api_key)
|
|
228
|
+
database_id = config.database_id.strip()
|
|
229
|
+
if not database_id:
|
|
230
|
+
projects = gateway.list_projects()
|
|
231
|
+
project = next(
|
|
232
|
+
(item for item in projects if not config.project_name or item.get("name") == config.project_name),
|
|
233
|
+
None,
|
|
234
|
+
)
|
|
235
|
+
if project:
|
|
236
|
+
databases = gateway.list_databases(project["id"])
|
|
237
|
+
database = next((item for item in databases if item.get("name") == inferred_name), None)
|
|
238
|
+
if database:
|
|
239
|
+
database_id = database["id"]
|
|
240
|
+
config.project_id = project["id"]
|
|
241
|
+
config.project_name = project.get("name", config.project_name)
|
|
242
|
+
if database_id:
|
|
243
|
+
try:
|
|
244
|
+
response = gateway.get_database_url(database_id, reveal=True)
|
|
245
|
+
recovered = response.get("database_url")
|
|
246
|
+
if recovered:
|
|
247
|
+
from parad.connection import parse_url
|
|
248
|
+
|
|
249
|
+
parsed = parse_url(recovered)
|
|
250
|
+
if name and parsed["name"] != name:
|
|
251
|
+
raise ValueError(
|
|
252
|
+
f"Recovered DATABASE_URL points to '{parsed['name']}', not '{name}'"
|
|
253
|
+
)
|
|
254
|
+
config.database_url = recovered
|
|
255
|
+
config.database_id = database_id
|
|
256
|
+
save_config(config)
|
|
257
|
+
return recovered
|
|
258
|
+
except GatewayError as exc:
|
|
259
|
+
if exc.status_code not in (404, 405, 501):
|
|
260
|
+
raise
|
|
261
|
+
|
|
262
|
+
return get_canonical_database_url(name)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def register_canonical_database_url(database_url: str) -> str:
|
|
266
|
+
"""Explicitly register a locally known canonical URL on the owner gateway.
|
|
267
|
+
|
|
268
|
+
This is the migration path for databases created before server URL storage;
|
|
269
|
+
it only updates the encrypted URL field and never initializes or snapshots
|
|
270
|
+
the database.
|
|
271
|
+
"""
|
|
272
|
+
config = load_config()
|
|
273
|
+
from parad.connection import parse_url
|
|
274
|
+
|
|
275
|
+
parsed = parse_url(database_url)
|
|
276
|
+
gateway_url = parsed.get("gateway_url", "").strip() or os.environ.get("PARADOX_GATEWAY", "").strip() or config.sync.gateway_url.strip()
|
|
277
|
+
api_key = os.environ.get("PARADOX_API_KEY", "").strip() or config.sync.api_key.strip() or parsed.get("token", "").strip()
|
|
278
|
+
if not gateway_url or not api_key:
|
|
279
|
+
raise ValueError("A gateway URL and owner API key are required to register DATABASE_URL")
|
|
280
|
+
|
|
281
|
+
from parad.gateway import GatewayClient
|
|
282
|
+
|
|
283
|
+
gateway = GatewayClient(gateway_url, api_key)
|
|
284
|
+
project_id = config.project_id.strip()
|
|
285
|
+
project_name = parsed.get("project") or config.project_name.strip()
|
|
286
|
+
if not project_id:
|
|
287
|
+
projects = gateway.list_projects()
|
|
288
|
+
project = next((item for item in projects if not project_name or item.get("name") == project_name), None)
|
|
289
|
+
if not project:
|
|
290
|
+
raise ValueError(f"Could not find project '{project_name or '(unspecified)'}'")
|
|
291
|
+
project_id = project["id"]
|
|
292
|
+
project_name = project.get("name", project_name)
|
|
293
|
+
databases = gateway.list_databases(project_id)
|
|
294
|
+
database = next((item for item in databases if item.get("name") == parsed["name"]), None)
|
|
295
|
+
if not database:
|
|
296
|
+
raise ValueError(f"Could not find database '{parsed['name']}' in project '{project_name or project_id}'")
|
|
297
|
+
gateway.set_database_url(database["id"], database_url)
|
|
298
|
+
config.database_url = database_url
|
|
299
|
+
config.database_id = database["id"]
|
|
300
|
+
config.project_id = project_id
|
|
301
|
+
if project_name:
|
|
302
|
+
config.project_name = project_name
|
|
303
|
+
save_config(config)
|
|
304
|
+
return database_url
|
|
305
|
+
|
|
306
|
+
|
|
202
307
|
def get_connection_url(name: str) -> str:
|
|
203
308
|
"""Backward-compatible alias for :func:`get_canonical_database_url`."""
|
|
204
309
|
return get_canonical_database_url(name)
|