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.
Files changed (46) hide show
  1. {parad-2.2.4 → parad-2.2.6}/PKG-INFO +23 -10
  2. {parad-2.2.4 → parad-2.2.6}/README.md +22 -9
  3. {parad-2.2.4 → parad-2.2.6}/parad/__init__.py +3 -3
  4. parad-2.2.6/parad/commands/auth.py +110 -0
  5. {parad-2.2.4 → parad-2.2.6}/parad/commands/connect.py +8 -9
  6. {parad-2.2.4 → parad-2.2.6}/parad/commands/init.py +8 -9
  7. parad-2.2.6/parad/commands/url.py +62 -0
  8. {parad-2.2.4 → parad-2.2.6}/parad/config.py +105 -0
  9. {parad-2.2.4 → parad-2.2.6}/parad/connection.py +36 -41
  10. {parad-2.2.4 → parad-2.2.6}/parad/gateway.py +28 -21
  11. {parad-2.2.4 → parad-2.2.6}/parad.egg-info/PKG-INFO +23 -10
  12. {parad-2.2.4 → parad-2.2.6}/pyproject.toml +1 -1
  13. {parad-2.2.4 → parad-2.2.6}/tests/test_connection.py +85 -41
  14. {parad-2.2.4 → parad-2.2.6}/tests/test_workflow.py +10 -20
  15. parad-2.2.4/parad/commands/auth.py +0 -109
  16. parad-2.2.4/parad/commands/url.py +0 -35
  17. {parad-2.2.4 → parad-2.2.6}/parad/__main__.py +0 -0
  18. {parad-2.2.4 → parad-2.2.6}/parad/cli.py +0 -0
  19. {parad-2.2.4 → parad-2.2.6}/parad/commands/__init__.py +0 -0
  20. {parad-2.2.4 → parad-2.2.6}/parad/commands/backups.py +0 -0
  21. {parad-2.2.4 → parad-2.2.6}/parad/commands/config_cmd.py +0 -0
  22. {parad-2.2.4 → parad-2.2.6}/parad/commands/databases.py +0 -0
  23. {parad-2.2.4 → parad-2.2.6}/parad/commands/projects.py +0 -0
  24. {parad-2.2.4 → parad-2.2.6}/parad/commands/query.py +0 -0
  25. {parad-2.2.4 → parad-2.2.6}/parad/commands/shell.py +0 -0
  26. {parad-2.2.4 → parad-2.2.6}/parad/commands/status.py +0 -0
  27. {parad-2.2.4 → parad-2.2.6}/parad/commands/sync.py +0 -0
  28. {parad-2.2.4 → parad-2.2.6}/parad/commands/versions.py +0 -0
  29. {parad-2.2.4 → parad-2.2.6}/parad/commands/watch.py +0 -0
  30. {parad-2.2.4 → parad-2.2.6}/parad/crypto.py +0 -0
  31. {parad-2.2.4 → parad-2.2.6}/parad/dbapi.py +0 -0
  32. {parad-2.2.4 → parad-2.2.6}/parad/engine.py +0 -0
  33. {parad-2.2.4 → parad-2.2.6}/parad/sqlalchemy.py +0 -0
  34. {parad-2.2.4 → parad-2.2.6}/parad/state.py +0 -0
  35. {parad-2.2.4 → parad-2.2.6}/parad/types.py +0 -0
  36. {parad-2.2.4 → parad-2.2.6}/parad/watcher.py +0 -0
  37. {parad-2.2.4 → parad-2.2.6}/parad.egg-info/SOURCES.txt +0 -0
  38. {parad-2.2.4 → parad-2.2.6}/parad.egg-info/dependency_links.txt +0 -0
  39. {parad-2.2.4 → parad-2.2.6}/parad.egg-info/entry_points.txt +0 -0
  40. {parad-2.2.4 → parad-2.2.6}/parad.egg-info/requires.txt +0 -0
  41. {parad-2.2.4 → parad-2.2.6}/parad.egg-info/top_level.txt +0 -0
  42. {parad-2.2.4 → parad-2.2.6}/setup.cfg +0 -0
  43. {parad-2.2.4 → parad-2.2.6}/tests/test_crypto.py +0 -0
  44. {parad-2.2.4 → parad-2.2.6}/tests/test_engine.py +0 -0
  45. {parad-2.2.4 → parad-2.2.6}/tests/test_sqlalchemy.py +0 -0
  46. {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.4
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
- # Auto-login (email:password), auto-provision project + database on the
50
- # gateway, open/create the local encrypted SQLite file.
51
- db = connect(url="parad://alice@example.com:secretpw@local/myproj/mydb?passphrase=secret")
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 without remote mutation
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 without opening, syncing, or mutating the remote database:
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
- # Auto-login (email:password), auto-provision project + database on the
24
- # gateway, open/create the local encrypted SQLite file.
25
- db = connect(url="parad://alice@example.com:secretpw@local/myproj/mydb?passphrase=secret")
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 without remote mutation
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 without opening, syncing, or mutating the remote database:
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.4"
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 credentials if no valid token.
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
- email = click.prompt("Email")
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
- result = gw.login(email, password)
40
- token = result.get("access_token") or result.get("api_key")
41
- if token:
42
- set_config_value("sync.api_key", token)
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("Login succeeded but no token received")
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 credentials if no valid token.
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
- email = click.prompt("Email")
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
- result = gw.login(email, password)
40
- token = result.get("access_token") or result.get("api_key")
41
- if token:
42
- set_config_value("sync.api_key", token)
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("Login succeeded but no token received")
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)