nexusquant-cli 0.4.0__tar.gz → 0.5.0__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.
- nexusquant_cli-0.4.0/README.md → nexusquant_cli-0.5.0/PKG-INFO +21 -0
- nexusquant_cli-0.4.0/PKG-INFO → nexusquant_cli-0.5.0/README.md +7 -14
- nexusquant_cli-0.5.0/nexusquant_cli/__init__.py +1 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/api_client.py +19 -3
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/config.py +7 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/identity.py +7 -1
- nexusquant_cli-0.5.0/nexusquant_cli/roles/auth.py +157 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/pyproject.toml +1 -1
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/tests/test_nexus_cli.py +70 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/uv.lock +1 -1
- nexusquant_cli-0.4.0/nexusquant_cli/__init__.py +0 -1
- nexusquant_cli-0.4.0/nexusquant_cli/roles/auth.py +0 -91
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/.gitignore +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/CLAUDE.md +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/docs/investor-operations.md +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/__main__.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/auth_pkce.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/__init__.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/_util.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/auth/__init__.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/auth/cmd.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/policy/__init__.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/policy/cmd.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/strategy/__init__.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/commands/strategy/cmd.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/embedded.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/main.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/nexus.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/roles/__init__.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/roles/_agent.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/roles/admin.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/roles/investor.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/nexusquant_cli/roles/provider.py +0 -0
- {nexusquant_cli-0.4.0 → nexusquant_cli-0.5.0}/tests/__init__.py +0 -0
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nexusquant-cli
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: NexusQuant CLI for users, strategy providers and admins
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Requires-Dist: nexusquant-sdk>=0.2
|
|
8
|
+
Requires-Dist: platformdirs>=4.2
|
|
9
|
+
Requires-Dist: rich>=13.7
|
|
10
|
+
Requires-Dist: typer>=0.12
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
1
15
|
# nexusquant-cli
|
|
2
16
|
|
|
3
17
|
Command line tool for NexusQuant investors, strategy providers and admins.
|
|
@@ -22,8 +36,15 @@ pip install nexusquant-cli
|
|
|
22
36
|
```bash
|
|
23
37
|
nexus-cli auth login # browser login; one login covers every role
|
|
24
38
|
nexus-cli auth whoami # account, userType, Wall Pro tier, usable roles
|
|
39
|
+
nexus-cli auth login --device # no local browser (server, SSH): approve a link + code from any browser
|
|
25
40
|
```
|
|
26
41
|
|
|
42
|
+
`--device` gives you a **Nexus AI token** instead of a Cognito login:
|
|
43
|
+
- It works for investor commands only; provider and admin commands are refused by the server.
|
|
44
|
+
- It is valid for 90 days and can be revoked at any time.
|
|
45
|
+
- `auth logout` revokes it on the server.
|
|
46
|
+
- The same token kind is what the Nexus AI Discord bot uses.
|
|
47
|
+
|
|
27
48
|
The role is not picked at login — it is the account's `custom:userType` (or the `admin` Cognito group), and the server checks it on every call. `whoami` reads it from the local token; on a 403 the CLI prints which account type is logged in.
|
|
28
49
|
|
|
29
50
|
Every read takes `--json` for scripting; the default output is a table.
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: nexusquant-cli
|
|
3
|
-
Version: 0.4.0
|
|
4
|
-
Summary: NexusQuant CLI for users, strategy providers and admins
|
|
5
|
-
Requires-Python: >=3.10
|
|
6
|
-
Requires-Dist: httpx>=0.27
|
|
7
|
-
Requires-Dist: nexusquant-sdk>=0.2
|
|
8
|
-
Requires-Dist: platformdirs>=4.2
|
|
9
|
-
Requires-Dist: rich>=13.7
|
|
10
|
-
Requires-Dist: typer>=0.12
|
|
11
|
-
Provides-Extra: dev
|
|
12
|
-
Requires-Dist: pytest>=8; extra == 'dev'
|
|
13
|
-
Description-Content-Type: text/markdown
|
|
14
|
-
|
|
15
1
|
# nexusquant-cli
|
|
16
2
|
|
|
17
3
|
Command line tool for NexusQuant investors, strategy providers and admins.
|
|
@@ -36,8 +22,15 @@ pip install nexusquant-cli
|
|
|
36
22
|
```bash
|
|
37
23
|
nexus-cli auth login # browser login; one login covers every role
|
|
38
24
|
nexus-cli auth whoami # account, userType, Wall Pro tier, usable roles
|
|
25
|
+
nexus-cli auth login --device # no local browser (server, SSH): approve a link + code from any browser
|
|
39
26
|
```
|
|
40
27
|
|
|
28
|
+
`--device` gives you a **Nexus AI token** instead of a Cognito login:
|
|
29
|
+
- It works for investor commands only; provider and admin commands are refused by the server.
|
|
30
|
+
- It is valid for 90 days and can be revoked at any time.
|
|
31
|
+
- `auth logout` revokes it on the server.
|
|
32
|
+
- The same token kind is what the Nexus AI Discord bot uses.
|
|
33
|
+
|
|
41
34
|
The role is not picked at login — it is the account's `custom:userType` (or the `admin` Cognito group), and the server checks it on every call. `whoami` reads it from the local token; on a 403 the CLI prints which account type is logged in.
|
|
42
35
|
|
|
43
36
|
Every read takes `--json` for scripting; the default output is a table.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
|
@@ -6,7 +6,7 @@ from typing import Any
|
|
|
6
6
|
import httpx
|
|
7
7
|
|
|
8
8
|
from nexusquant_cli.auth_pkce import ensure_fresh_id_token
|
|
9
|
-
from nexusquant_cli.config import api_base_url, token_override
|
|
9
|
+
from nexusquant_cli.config import api_base_url, stored_nexus_ai_token, token_override
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class ApiError(RuntimeError):
|
|
@@ -20,7 +20,7 @@ class ApiError(RuntimeError):
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def _headers(*, force_refresh: bool = False) -> dict[str, str]:
|
|
23
|
-
token = token_override() or ensure_fresh_id_token(force_refresh=force_refresh)
|
|
23
|
+
token = token_override() or stored_nexus_ai_token() or ensure_fresh_id_token(force_refresh=force_refresh)
|
|
24
24
|
return {
|
|
25
25
|
"Authorization": f"Bearer {token}",
|
|
26
26
|
"Accept": "application/json",
|
|
@@ -58,7 +58,8 @@ def request(
|
|
|
58
58
|
kwargs["json"] = body
|
|
59
59
|
with httpx.Client(timeout=timeout) as client:
|
|
60
60
|
response = client.request(method, url, headers=_headers(), **kwargs)
|
|
61
|
-
|
|
61
|
+
# Only a Cognito login can be refreshed; a Nexus AI token's tier is checked live by the server.
|
|
62
|
+
if _is_stale_tier_403(response) and token_override() is None and stored_nexus_ai_token() is None:
|
|
62
63
|
response = client.request(
|
|
63
64
|
method, url, headers=_headers(force_refresh=True), **kwargs
|
|
64
65
|
)
|
|
@@ -68,6 +69,21 @@ def request(
|
|
|
68
69
|
return response.json()
|
|
69
70
|
|
|
70
71
|
|
|
72
|
+
def public_post(path: str, body: Any, *, timeout: float = 30.0) -> tuple[int, Any]:
|
|
73
|
+
"""POST without any credentials (device login). Returns (status, json) instead of raising."""
|
|
74
|
+
with httpx.Client(timeout=timeout) as client:
|
|
75
|
+
response = client.post(f"{api_base_url()}{path}", json=body, headers={"Accept": "application/json"})
|
|
76
|
+
try:
|
|
77
|
+
return response.status_code, response.json()
|
|
78
|
+
except json.JSONDecodeError:
|
|
79
|
+
return response.status_code, {"error": response.text[:200]}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def revoke_current_token() -> Any:
|
|
83
|
+
"""DELETE /nexus-ai/tokens/current/ — revoke the Nexus AI token this CLI is using."""
|
|
84
|
+
return request("DELETE", "/nexus-ai/tokens/current/")
|
|
85
|
+
|
|
86
|
+
|
|
71
87
|
def get_json(
|
|
72
88
|
path: str, *, params: dict[str, Any] | None = None, timeout: float = 60.0
|
|
73
89
|
) -> Any:
|
|
@@ -67,6 +67,13 @@ def token_override() -> str | None:
|
|
|
67
67
|
return _TOKEN_OVERRIDE
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def stored_nexus_ai_token() -> str | None:
|
|
71
|
+
"""A Nexus AI token saved by `auth login --device` (investor commands only)."""
|
|
72
|
+
creds = load_credentials() or {}
|
|
73
|
+
tok = creds.get("nexus_ai_token")
|
|
74
|
+
return str(tok) if tok and not creds.get("id_token") else None
|
|
75
|
+
|
|
76
|
+
|
|
70
77
|
def load_credentials() -> dict[str, Any] | None:
|
|
71
78
|
if _TOKEN_OVERRIDE is not None:
|
|
72
79
|
return {"id_token": _TOKEN_OVERRIDE}
|
|
@@ -21,6 +21,10 @@ def _decode_segment(segment: str) -> dict[str, Any]:
|
|
|
21
21
|
|
|
22
22
|
def token_claims() -> dict[str, Any] | None:
|
|
23
23
|
creds = load_credentials()
|
|
24
|
+
if creds and creds.get("nexus_ai_token") and not creds.get("id_token"):
|
|
25
|
+
# Device login: not a JWT. Investor-only by construction; tier is checked live by the server.
|
|
26
|
+
return {"email": creds.get("email"), "sub": creds.get("sub"), "nexus_ai_token": True,
|
|
27
|
+
"nexus_ai_expires_at": creds.get("expires_at")}
|
|
24
28
|
token = (creds or {}).get("id_token")
|
|
25
29
|
if not token:
|
|
26
30
|
return None
|
|
@@ -43,7 +47,9 @@ def user_type(claims: dict[str, Any] | None) -> str:
|
|
|
43
47
|
|
|
44
48
|
def roles_for(claims: dict[str, Any] | None) -> list[str]:
|
|
45
49
|
"""Role groups this login can use. Mirrors the server: admin passes provider
|
|
46
|
-
checks too; every login is an investor."""
|
|
50
|
+
checks too; every login is an investor. A Nexus AI token is investor-only."""
|
|
51
|
+
if (claims or {}).get("nexus_ai_token"):
|
|
52
|
+
return ["investor"]
|
|
47
53
|
t = user_type(claims)
|
|
48
54
|
if t == "admin":
|
|
49
55
|
return ["admin", "provider", "investor"]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""`nexus-cli auth …` — one login for every role.
|
|
2
|
+
|
|
3
|
+
The role is not chosen at login: it is ``custom:userType`` on the account, and the
|
|
4
|
+
server checks it per request. ``whoami`` shows which role groups apply.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from nexusquant_cli.auth_pkce import ensure_fresh_id_token, login_and_save
|
|
15
|
+
from nexusquant_cli.commands._util import fail
|
|
16
|
+
from nexusquant_cli.config import CREDENTIALS_PATH, clear_credentials, load_credentials
|
|
17
|
+
from nexusquant_cli.identity import has_wall_pro, roles_for, token_claims, user_type
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
auth_app = typer.Typer(
|
|
22
|
+
help="Login / logout. One login covers every role; the account's userType decides "
|
|
23
|
+
"which of admin / provider / investor commands the server accepts.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@auth_app.command(
|
|
29
|
+
"login",
|
|
30
|
+
help="Log in. Default: open the browser on this machine (Cognito). --device: show a link and code to "
|
|
31
|
+
"approve from any browser (for servers / no local browser); gets a Nexus AI token that works for "
|
|
32
|
+
"investor commands only, valid 90 days.",
|
|
33
|
+
)
|
|
34
|
+
def login(
|
|
35
|
+
device: bool = typer.Option(False, "--device", help="Device login: approve in any browser."),
|
|
36
|
+
label: str = typer.Option("", "--label", help="Name shown when you approve, e.g. this machine's name."),
|
|
37
|
+
) -> None:
|
|
38
|
+
if device:
|
|
39
|
+
_device_login(label)
|
|
40
|
+
return
|
|
41
|
+
try:
|
|
42
|
+
login_and_save()
|
|
43
|
+
except Exception as e: # noqa: BLE001
|
|
44
|
+
fail(console, e)
|
|
45
|
+
console.print("[green]Logged in.[/green]")
|
|
46
|
+
whoami(as_json=False)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _device_login(label: str) -> None:
|
|
50
|
+
import socket
|
|
51
|
+
|
|
52
|
+
from nexusquant_cli import api_client
|
|
53
|
+
from nexusquant_cli.config import save_credentials
|
|
54
|
+
|
|
55
|
+
code, body = api_client.public_post("/nexus-ai/device/start/", {"client": "cli", "label": label or socket.gethostname()})
|
|
56
|
+
if code != 200:
|
|
57
|
+
fail(console, RuntimeError(f"HTTP {code}: {body}"))
|
|
58
|
+
d = body.get("data") or {}
|
|
59
|
+
console.print(f"Open [bold]{d.get('verification_uri')}[/bold]")
|
|
60
|
+
console.print(f"and approve code [bold cyan]{d.get('user_code')}[/bold cyan] (expires in {int(d.get('expires_in', 600)) // 60} min).")
|
|
61
|
+
deadline = time.monotonic() + float(d.get("expires_in") or 600)
|
|
62
|
+
interval = max(2.0, float(d.get("interval") or 5))
|
|
63
|
+
with console.status("Waiting for approval…"):
|
|
64
|
+
while time.monotonic() < deadline:
|
|
65
|
+
time.sleep(interval)
|
|
66
|
+
code, body = api_client.public_post("/nexus-ai/device/token/", {"device_code": d.get("device_code")})
|
|
67
|
+
err = (body or {}).get("error_code")
|
|
68
|
+
if code == 200:
|
|
69
|
+
data = body.get("data") or {}
|
|
70
|
+
save_credentials({
|
|
71
|
+
"nexus_ai_token": data["access_token"],
|
|
72
|
+
"expires_at": data.get("expires_at"),
|
|
73
|
+
"sub": (data.get("user") or {}).get("sub"),
|
|
74
|
+
"email": (data.get("user") or {}).get("email"),
|
|
75
|
+
"saved_at": time.time(),
|
|
76
|
+
})
|
|
77
|
+
console.print("[green]Logged in with a Nexus AI token[/green] (investor commands only).")
|
|
78
|
+
whoami(as_json=False)
|
|
79
|
+
return
|
|
80
|
+
if err != "AUTHORIZATION_PENDING":
|
|
81
|
+
fail(console, RuntimeError(f"{err or code}: {(body or {}).get('error')}"))
|
|
82
|
+
fail(console, RuntimeError("Timed out waiting for approval. Run `nexus-cli auth login --device` again."))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@auth_app.command("logout", help="Delete locally saved tokens (a Nexus AI token is also revoked on the server).")
|
|
86
|
+
def logout() -> None:
|
|
87
|
+
creds = load_credentials() or {}
|
|
88
|
+
if creds.get("nexus_ai_token") and not creds.get("id_token"):
|
|
89
|
+
from nexusquant_cli import api_client
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
api_client.revoke_current_token()
|
|
93
|
+
console.print("[dim]Nexus AI token revoked on the server.[/dim]")
|
|
94
|
+
except Exception as e: # noqa: BLE001 — still clear locally
|
|
95
|
+
console.print(f"[yellow]Could not revoke on the server ({e}); clearing locally anyway.[/yellow]")
|
|
96
|
+
clear_credentials()
|
|
97
|
+
console.print("[green]Logged out (local tokens removed).[/green]")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@auth_app.command("refresh", help="Force-refresh the ID token (e.g. right after a Wall Pro upgrade).")
|
|
101
|
+
def refresh() -> None:
|
|
102
|
+
try:
|
|
103
|
+
ensure_fresh_id_token(force_refresh=True)
|
|
104
|
+
except Exception as e: # noqa: BLE001
|
|
105
|
+
fail(console, e)
|
|
106
|
+
console.print("[green]Token refreshed.[/green]")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@auth_app.command("status", help="Whether tokens are saved on this machine (no network call).")
|
|
110
|
+
def status() -> None:
|
|
111
|
+
creds = load_credentials()
|
|
112
|
+
if creds and creds.get("id_token"):
|
|
113
|
+
console.print(f"[green]Logged in.[/green] Credentials: {CREDENTIALS_PATH}")
|
|
114
|
+
else:
|
|
115
|
+
console.print("[yellow]Not logged in. Run: nexus-cli auth login[/yellow]")
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@auth_app.command(
|
|
120
|
+
"whoami",
|
|
121
|
+
help="Show the logged-in account, its userType, Wall Pro tier, and usable role groups. "
|
|
122
|
+
"Read from the local token; the server remains the authority.",
|
|
123
|
+
)
|
|
124
|
+
def whoami(
|
|
125
|
+
as_json: bool = typer.Option(False, "--json", help="Print raw claims subset as JSON."),
|
|
126
|
+
) -> None:
|
|
127
|
+
claims = token_claims()
|
|
128
|
+
if claims is None:
|
|
129
|
+
console.print("[yellow]Not logged in. Run: nexus-cli auth login[/yellow]")
|
|
130
|
+
raise typer.Exit(1)
|
|
131
|
+
exp = claims.get("exp")
|
|
132
|
+
if claims.get("nexus_ai_token"):
|
|
133
|
+
info = {"email": claims.get("email"), "login": "nexus_ai_token", "roles": ["investor"],
|
|
134
|
+
"expires_at": claims.get("nexus_ai_expires_at")}
|
|
135
|
+
if as_json:
|
|
136
|
+
console.print_json(data=info)
|
|
137
|
+
return
|
|
138
|
+
console.print(f"[bold]{info['email']}[/bold]")
|
|
139
|
+
console.print("login Nexus AI token (device login) — investor commands only")
|
|
140
|
+
console.print(f"expires {info['expires_at']}")
|
|
141
|
+
return
|
|
142
|
+
info = {
|
|
143
|
+
"email": claims.get("email"),
|
|
144
|
+
"user_type": user_type(claims),
|
|
145
|
+
"wall_pro": has_wall_pro(claims),
|
|
146
|
+
"roles": roles_for(claims),
|
|
147
|
+
"token_expires_in_s": int(exp - time.time()) if isinstance(exp, (int, float)) else None,
|
|
148
|
+
}
|
|
149
|
+
if as_json:
|
|
150
|
+
console.print_json(data=info)
|
|
151
|
+
return
|
|
152
|
+
console.print(f"[bold]{info['email']}[/bold]")
|
|
153
|
+
console.print(f"userType {info['user_type']}")
|
|
154
|
+
console.print(f"Wall Pro {'[green]yes[/green]' if info['wall_pro'] else '[yellow]no[/yellow]'}")
|
|
155
|
+
console.print(f"roles {' / '.join(info['roles'])}")
|
|
156
|
+
if info["token_expires_in_s"] is not None and info["token_expires_in_s"] < 0:
|
|
157
|
+
console.print("[dim]ID token expired; it is refreshed automatically on the next call.[/dim]")
|
|
@@ -641,3 +641,73 @@ def test_embedded_broker_gate(argv, confirmed, allowed) -> None:
|
|
|
641
641
|
from nexusquant_cli import embedded
|
|
642
642
|
|
|
643
643
|
assert (embedded.validate(argv, write_confirmed=confirmed) is None) is allowed
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
# ── device login (Nexus AI token) ───────────────────────────────────────────
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@pytest.fixture
|
|
650
|
+
def creds_dir(tmp_path, monkeypatch):
|
|
651
|
+
from nexusquant_cli import config
|
|
652
|
+
|
|
653
|
+
monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
|
|
654
|
+
monkeypatch.setattr(config, "CREDENTIALS_PATH", tmp_path / "credentials.json")
|
|
655
|
+
return tmp_path / "credentials.json"
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _device_routes(http: Recorder, outcomes):
|
|
659
|
+
http.on("POST", "/nexus-ai/device/start/", {"data": {
|
|
660
|
+
"device_code": "nxdc_x", "user_code": "BCDF-GHJK", "verification_uri": "https://nexusquant.co/link?code=BCDF-GHJK",
|
|
661
|
+
"expires_in": 600, "interval": 5}})
|
|
662
|
+
seq = iter(outcomes)
|
|
663
|
+
http.on("POST", "/nexus-ai/device/token/", lambda r: next(seq))
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def test_device_login_saves_token_and_uses_it(http: Recorder, creds_dir, monkeypatch) -> None:
|
|
667
|
+
from nexusquant_cli.roles import auth as auth_mod
|
|
668
|
+
|
|
669
|
+
monkeypatch.setattr(auth_mod.time, "sleep", lambda s: None)
|
|
670
|
+
_device_routes(http, [
|
|
671
|
+
httpx.Response(400, json={"error_code": "AUTHORIZATION_PENDING"}),
|
|
672
|
+
httpx.Response(200, json={"data": {"access_token": "nxat_secret_123", "expires_at": "2027-01-08T00:00:00Z",
|
|
673
|
+
"user": {"sub": "sub-1", "email": "a@x.com"}}}),
|
|
674
|
+
])
|
|
675
|
+
result = runner.invoke(app, ["auth", "login", "--device", "--label", "box"])
|
|
676
|
+
assert result.exit_code == 0, result.output
|
|
677
|
+
assert "BCDF-GHJK" in result.output and "https://nexusquant.co/link?code=BCDF-GHJK" in result.output
|
|
678
|
+
assert "investor commands only" in result.output
|
|
679
|
+
saved = json.loads(creds_dir.read_text())
|
|
680
|
+
assert saved["nexus_ai_token"] == "nxat_secret_123" and "id_token" not in saved
|
|
681
|
+
assert http.calls[0][3] == {"client": "cli", "label": "box"}
|
|
682
|
+
assert "nxat_secret_123" not in result.output
|
|
683
|
+
|
|
684
|
+
# 之后的请求用这个 token(http 夹具把 Cognito 登录换成了 "tok",这里必须不是它)
|
|
685
|
+
seen = []
|
|
686
|
+
|
|
687
|
+
def stats(request: httpx.Request) -> httpx.Response:
|
|
688
|
+
seen.append(request.headers["Authorization"])
|
|
689
|
+
return httpx.Response(200, json={"data": {}})
|
|
690
|
+
|
|
691
|
+
http.on("GET", "/strategy-signal/statistics/", stats)
|
|
692
|
+
assert runner.invoke(app, ["investor", "read", "stats", "--json"]).exit_code == 0
|
|
693
|
+
assert seen == ["Bearer nxat_secret_123"]
|
|
694
|
+
|
|
695
|
+
who = runner.invoke(app, ["auth", "whoami", "--json"])
|
|
696
|
+
assert json.loads(who.output)["roles"] == ["investor"]
|
|
697
|
+
|
|
698
|
+
http.on("DELETE", "/nexus-ai/tokens/current/", {"data": {}})
|
|
699
|
+
out = runner.invoke(app, ["auth", "logout"])
|
|
700
|
+
assert out.exit_code == 0, out.output
|
|
701
|
+
assert http.calls[-1][:2] == ("DELETE", "/nexus-ai/tokens/current/")
|
|
702
|
+
assert not creds_dir.exists()
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def test_device_login_denied(http: Recorder, creds_dir, monkeypatch) -> None:
|
|
706
|
+
from nexusquant_cli.roles import auth as auth_mod
|
|
707
|
+
|
|
708
|
+
monkeypatch.setattr(auth_mod.time, "sleep", lambda s: None)
|
|
709
|
+
_device_routes(http, [httpx.Response(400, json={"error_code": "ACCESS_DENIED", "error": "用户拒绝了授权"})])
|
|
710
|
+
result = runner.invoke(app, ["auth", "login", "--device"])
|
|
711
|
+
assert result.exit_code == 1
|
|
712
|
+
assert "ACCESS_DENIED" in result.output
|
|
713
|
+
assert not creds_dir.exists()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.4.0"
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
"""`nexus-cli auth …` — one login for every role.
|
|
2
|
-
|
|
3
|
-
The role is not chosen at login: it is ``custom:userType`` on the account, and the
|
|
4
|
-
server checks it per request. ``whoami`` shows which role groups apply.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import time
|
|
10
|
-
|
|
11
|
-
import typer
|
|
12
|
-
from rich.console import Console
|
|
13
|
-
|
|
14
|
-
from nexusquant_cli.auth_pkce import ensure_fresh_id_token, login_and_save
|
|
15
|
-
from nexusquant_cli.commands._util import fail
|
|
16
|
-
from nexusquant_cli.config import CREDENTIALS_PATH, clear_credentials, load_credentials
|
|
17
|
-
from nexusquant_cli.identity import has_wall_pro, roles_for, token_claims, user_type
|
|
18
|
-
|
|
19
|
-
console = Console()
|
|
20
|
-
|
|
21
|
-
auth_app = typer.Typer(
|
|
22
|
-
help="Login / logout. One login covers every role; the account's userType decides "
|
|
23
|
-
"which of admin / provider / investor commands the server accepts.",
|
|
24
|
-
no_args_is_help=True,
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@auth_app.command("login", help="Open the browser for Cognito login and save tokens locally.")
|
|
29
|
-
def login() -> None:
|
|
30
|
-
try:
|
|
31
|
-
login_and_save()
|
|
32
|
-
except Exception as e: # noqa: BLE001
|
|
33
|
-
fail(console, e)
|
|
34
|
-
console.print("[green]Logged in.[/green]")
|
|
35
|
-
whoami(as_json=False)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
@auth_app.command("logout", help="Delete locally saved tokens.")
|
|
39
|
-
def logout() -> None:
|
|
40
|
-
clear_credentials()
|
|
41
|
-
console.print("[green]Logged out (local tokens removed).[/green]")
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
@auth_app.command("refresh", help="Force-refresh the ID token (e.g. right after a Wall Pro upgrade).")
|
|
45
|
-
def refresh() -> None:
|
|
46
|
-
try:
|
|
47
|
-
ensure_fresh_id_token(force_refresh=True)
|
|
48
|
-
except Exception as e: # noqa: BLE001
|
|
49
|
-
fail(console, e)
|
|
50
|
-
console.print("[green]Token refreshed.[/green]")
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@auth_app.command("status", help="Whether tokens are saved on this machine (no network call).")
|
|
54
|
-
def status() -> None:
|
|
55
|
-
creds = load_credentials()
|
|
56
|
-
if creds and creds.get("id_token"):
|
|
57
|
-
console.print(f"[green]Logged in.[/green] Credentials: {CREDENTIALS_PATH}")
|
|
58
|
-
else:
|
|
59
|
-
console.print("[yellow]Not logged in. Run: nexus-cli auth login[/yellow]")
|
|
60
|
-
raise typer.Exit(1)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
@auth_app.command(
|
|
64
|
-
"whoami",
|
|
65
|
-
help="Show the logged-in account, its userType, Wall Pro tier, and usable role groups. "
|
|
66
|
-
"Read from the local token; the server remains the authority.",
|
|
67
|
-
)
|
|
68
|
-
def whoami(
|
|
69
|
-
as_json: bool = typer.Option(False, "--json", help="Print raw claims subset as JSON."),
|
|
70
|
-
) -> None:
|
|
71
|
-
claims = token_claims()
|
|
72
|
-
if claims is None:
|
|
73
|
-
console.print("[yellow]Not logged in. Run: nexus-cli auth login[/yellow]")
|
|
74
|
-
raise typer.Exit(1)
|
|
75
|
-
exp = claims.get("exp")
|
|
76
|
-
info = {
|
|
77
|
-
"email": claims.get("email"),
|
|
78
|
-
"user_type": user_type(claims),
|
|
79
|
-
"wall_pro": has_wall_pro(claims),
|
|
80
|
-
"roles": roles_for(claims),
|
|
81
|
-
"token_expires_in_s": int(exp - time.time()) if isinstance(exp, (int, float)) else None,
|
|
82
|
-
}
|
|
83
|
-
if as_json:
|
|
84
|
-
console.print_json(data=info)
|
|
85
|
-
return
|
|
86
|
-
console.print(f"[bold]{info['email']}[/bold]")
|
|
87
|
-
console.print(f"userType {info['user_type']}")
|
|
88
|
-
console.print(f"Wall Pro {'[green]yes[/green]' if info['wall_pro'] else '[yellow]no[/yellow]'}")
|
|
89
|
-
console.print(f"roles {' / '.join(info['roles'])}")
|
|
90
|
-
if info["token_expires_in_s"] is not None and info["token_expires_in_s"] < 0:
|
|
91
|
-
console.print("[dim]ID token expired; it is refreshed automatically on the next call.[/dim]")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|