From 1c96c4473e19c69333ba119be1f21a1df701e6e4 Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 18:19:11 +0200 Subject: [PATCH 1/6] feat(login): derive the managed API URL Use the live data-plane hostname already returned by the token host when --api-url is absent. Refuse not-yet-live or missing hostnames without writing credentials, while preserving the explicit override for self-hosted and local deployments. --- src/rememberstack/surfaces/cli.py | 17 ++-- src/rememberstack/surfaces/device_login.py | 22 ++++-- src/tests/surfaces/test_login.py | 86 +++++++++++++++++++++ website/src/app/docs/reference/cli/page.mdx | 16 +++- 4 files changed, 129 insertions(+), 12 deletions(-) diff --git a/src/rememberstack/surfaces/cli.py b/src/rememberstack/surfaces/cli.py index 3c503841..faf9369c 100644 --- a/src/rememberstack/surfaces/cli.py +++ b/src/rememberstack/surfaces/cli.py @@ -641,7 +641,6 @@ def _login_locked(args: argparse.Namespace) -> int: """The login itself, with the credential lock already held.""" from rememberstack.surfaces.credentials import append_pending_revocation from rememberstack.surfaces.credentials import assert_revocation_capacity - from rememberstack.surfaces.credentials import CliClientEnv from rememberstack.surfaces.credentials import credential_origin from rememberstack.surfaces.credentials import CredentialError from rememberstack.surfaces.credentials import drop_pending_revocation @@ -654,8 +653,7 @@ def _login_locked(args: argparse.Namespace) -> int: from rememberstack.surfaces.device_login import DeviceGrantError from rememberstack.surfaces.device_login import poll_device_token - env = CliClientEnv.model_validate({}) - api_url = args.api_url or env.api_url or "http://127.0.0.1:8000" + api_url = args.api_url try: token_host = _resolved_token_host(explicit=args.token_host) except ValueError as error: @@ -762,9 +760,16 @@ def orphaned(payload: object) -> None: # success, take it back out of the journal — it is the current # credential now, not one awaiting revocation. try: - credential = credential_from_token( - token=token, api_url=api_url, token_host=token_host - ) + try: + credential = credential_from_token( + token=token, api_url=api_url, token_host=token_host + ) + except DeviceGrantError: + # The poll already journalled the minted bearer. Retire it + # now when possible; if the host cannot confirm that, the + # journal keeps the only secret needed for a later retry. + _retry_pending_revocation() + raise if existing is not None: # Written before the file is overwritten, because # overwriting it destroys the only copy of the diff --git a/src/rememberstack/surfaces/device_login.py b/src/rememberstack/surfaces/device_login.py index c82bc977..02436c6e 100644 --- a/src/rememberstack/surfaces/device_login.py +++ b/src/rememberstack/surfaces/device_login.py @@ -67,9 +67,9 @@ class DeviceTokenSuccess(BaseModel): server may add, and the client must carry on. Fields this client actually needs are declared and validated; anything else is the server's business. - Known-but-unused fields are declared explicitly rather than swallowed, so a - reader can see what the server sends and a future change to use one does not - have to rediscover it. + The advertised data-plane hostname is declared explicitly because login uses + it to configure managed deployments without a separate ``--api-url``. Other + additive fields remain safe to ignore until the client needs them. """ model_config = ConfigDict(extra="ignore", frozen=True, hide_input_in_errors=True) @@ -283,9 +283,21 @@ def revoke_self(*, client: httpx.Client, access_token: str) -> int: def credential_from_token( - *, token: DeviceTokenSuccess, api_url: str, token_host: str + *, token: DeviceTokenSuccess, api_url: str | None, token_host: str ) -> CredentialFile: - """Build the v1 credential document from a successful poll.""" + """Build the v1 credential document, deriving its managed API URL.""" + if api_url is None: + hostname = token.data_plane_hostname + if hostname is None or not hostname.strip(): + raise DeviceGrantError( + "token host did not advertise a data-plane hostname; pass --api-url" + ) + if not token.data_plane_hostname_live: + raise DeviceGrantError( + f"deployment hostname: {hostname}\n" + "your deployment is not live yet; run `remember login` again when it is" + ) + api_url = f"https://{hostname}" return CredentialFile( version=1, api_url=api_url, diff --git a/src/tests/surfaces/test_login.py b/src/tests/surfaces/test_login.py index 8736ab1e..dd68fb6b 100644 --- a/src/tests/surfaces/test_login.py +++ b/src/tests/surfaces/test_login.py @@ -488,6 +488,92 @@ def factory(*args: object, **kwargs: object) -> httpx.Client: monkeypatch.setattr(httpx, "Client", factory) +def test_login_derives_the_api_url_from_a_live_hostname( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A managed login needs only the token host once its deployment is live.""" + _isolate_config(monkeypatch, tmp_path) + calls: list[str] = [] + hostname = f"{_DEPLOYMENT_ID}.dp.remember.dev" + _mock_client( + monkeypatch, + _grant_handler( + token_body=_token_body( + data_plane_hostname=hostname, data_plane_hostname_live=True + ), + calls=calls, + ), + ) + + assert cli_main(["login", "--token-host", _TOKEN_HOST]) == 0 + stored = load_credentials() + assert stored is not None + assert stored.api_url == f"https://{hostname}" + + +def test_login_api_url_override_wins_over_an_unready_hostname( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Self-host and local users can explicitly choose their API endpoint.""" + _isolate_config(monkeypatch, tmp_path) + calls: list[str] = [] + _mock_client( + monkeypatch, + _grant_handler( + token_body=_token_body( + data_plane_hostname="not-live.dp.remember.dev", + data_plane_hostname_live=False, + ), + calls=calls, + ), + ) + + assert cli_main(["login", "--token-host", _TOKEN_HOST, "--api-url", _API]) == 0 + stored = load_credentials() + assert stored is not None + assert stored.api_url == _API + + +def test_login_refuses_a_hostname_that_is_not_live( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Do not persist a managed endpoint before the control plane says it is live.""" + _isolate_config(monkeypatch, tmp_path) + calls: list[str] = [] + hostname = f"{_DEPLOYMENT_ID}.dp.remember.dev" + _mock_client( + monkeypatch, + _grant_handler( + token_body=_token_body( + data_plane_hostname=hostname, data_plane_hostname_live=False + ), + calls=calls, + ), + ) + + assert cli_main(["login", "--token-host", _TOKEN_HOST]) == 1 + captured = capsys.readouterr() + assert hostname in captured.err + assert "your deployment is not live yet" in captured.err + assert "run `remember login` again when it is" in captured.err + assert load_credentials() is None + assert calls == ["authorize", "token", "revoke"] + + +def test_login_without_an_advertised_hostname_asks_for_api_url( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Older and self-hosted token services still need an explicit API URL.""" + _isolate_config(monkeypatch, tmp_path) + calls: list[str] = [] + _mock_client(monkeypatch, _grant_handler(token_body=_token_body(), calls=calls)) + + assert cli_main(["login", "--token-host", _TOKEN_HOST]) == 1 + assert "--api-url" in capsys.readouterr().err + assert load_credentials() is None + assert calls == ["authorize", "token", "revoke"] + + def test_login_revokes_the_predecessor_only_after_minting( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/website/src/app/docs/reference/cli/page.mdx b/website/src/app/docs/reference/cli/page.mdx index 3b027b92..7e35cbf6 100644 --- a/website/src/app/docs/reference/cli/page.mdx +++ b/website/src/app/docs/reference/cli/page.mdx @@ -40,13 +40,27 @@ remember --version Device-grant login lives in the base wheel. `--token-host` (or `REMEMBERSTACK_TOKEN_HOST`) is required on login and is **never** derived from `--api-url`. The token host is the service that mints deployment tokens; it is -not the query API. +not the query API. For a managed deployment whose advertised hostname is live, +the token response supplies the API URL, so only the token host is needed: + +```bash +remember login --token-host https://token-host.example +``` + +For a self-hosted or local deployment, pass its API URL explicitly. This +override wins even if the token host advertises a different or not-yet-live +hostname: ```bash remember login --token-host https://token-host.example --api-url http://127.0.0.1:8000 remember logout ``` +Without `--api-url`, login refuses an advertised hostname that is not live, +prints that hostname, and asks you to run `remember login` again when it is. If +the token host advertises no hostname, login asks for `--api-url`. Neither +refusal writes a credential file. + `login` prints the user code and both verification URLs, then polls `POST {token_host}/v1/device/token`. It never prints `device_code` or `access_token`. The credential file is From ae907b2bf7bcc63d1e706d3574da3e855f46369e Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 20:11:06 +0200 Subject: [PATCH 2/6] fix(login): validate the advertised deployment host Normalize and reject malformed managed hostnames, accept nullable readiness from the separately deployed token service, and preserve predecessor credentials on refusal. Document why only an explicit API URL overrides the credential-bound hostname. --- ...st_path_metering_and_cost_export_design.md | 10 +++- src/rememberstack/surfaces/cli.py | 3 + src/rememberstack/surfaces/device_login.py | 25 ++++++-- .../surfaces/test_device_login_contract.py | 10 ++++ src/tests/surfaces/test_login.py | 57 ++++++++++++++++++- website/src/app/docs/configuration/page.mdx | 2 +- website/src/app/docs/project-status/page.mdx | 9 +++ website/src/app/docs/reference/cli/page.mdx | 4 +- 8 files changed, 108 insertions(+), 12 deletions(-) diff --git a/plan/designs/request_path_metering_and_cost_export_design.md b/plan/designs/request_path_metering_and_cost_export_design.md index 5da3660e..2f224a77 100644 --- a/plan/designs/request_path_metering_and_cost_export_design.md +++ b/plan/designs/request_path_metering_and_cost_export_design.md @@ -644,9 +644,13 @@ remember logout [--token-host URL] There is **no** derivation of a token host from `--api-url`. The engine must not encode a commercial control plane’s `/dp/v1` layout. -`--api-url` on login is the query API stored in the file (default: -`REMEMBERSTACK_API_URL` or `http://127.0.0.1:8000`). It is not the -device-grant host. +`--api-url` on login is an explicit query-API override stored in the file. If +it is omitted, login derives `https://{data_plane_hostname}` from a live +hostname advertised by the token host. It does not fall back to +`REMEMBERSTACK_API_URL` or localhost: doing so could bind a newly minted +deployment credential to an unrelated endpoint. A self-hosted or local token +host that does not advertise a hostname therefore requires the flag. The query +API is not the device-grant host. `logout` uses `--token-host`, else `REMEMBERSTACK_TOKEN_HOST`, else the file’s `token_host`. It does not take `--api-url`. diff --git a/src/rememberstack/surfaces/cli.py b/src/rememberstack/surfaces/cli.py index faf9369c..8fd79bf1 100644 --- a/src/rememberstack/surfaces/cli.py +++ b/src/rememberstack/surfaces/cli.py @@ -653,6 +653,9 @@ def _login_locked(args: argparse.Namespace) -> int: from rememberstack.surfaces.device_login import DeviceGrantError from rememberstack.surfaces.device_login import poll_device_token + # Login binds a newly minted deployment credential. Only the explicit flag + # may override that deployment's advertised host; a process-wide API URL + # can legitimately point at some other deployment. api_url = args.api_url try: token_host = _resolved_token_host(explicit=args.token_host) diff --git a/src/rememberstack/surfaces/device_login.py b/src/rememberstack/surfaces/device_login.py index 02436c6e..3dfd5b3f 100644 --- a/src/rememberstack/surfaces/device_login.py +++ b/src/rememberstack/surfaces/device_login.py @@ -10,11 +10,13 @@ from collections.abc import Mapping from datetime import datetime import time +from typing import Annotated from typing import Literal from uuid import UUID import httpx from pydantic import BaseModel +from pydantic import BeforeValidator from pydantic import ConfigDict from pydantic import Field from pydantic import SecretStr @@ -85,7 +87,9 @@ class DeviceTokenSuccess(BaseModel): #: Advertised by the control plane; the CLI stores it so a caller does not #: have to be told the host separately. data_plane_hostname: str | None = None - data_plane_hostname_live: bool = False + data_plane_hostname_live: Annotated[ + bool, BeforeValidator(lambda value: False if value is None else value) + ] = False #: When the credential stops working (D60). Absent for the unexpiring #: tokens minted before that decision, which is why it is optional rather #: than required — a client that demanded it would refuse today's tokens. @@ -286,16 +290,25 @@ def credential_from_token( *, token: DeviceTokenSuccess, api_url: str | None, token_host: str ) -> CredentialFile: """Build the v1 credential document, deriving its managed API URL.""" - if api_url is None: - hostname = token.data_plane_hostname - if hostname is None or not hostname.strip(): + api_url = (api_url or "").strip() + if not api_url: + hostname = (token.data_plane_hostname or "").strip() + if not hostname: raise DeviceGrantError( "token host did not advertise a data-plane hostname; pass --api-url" ) + if ( + "://" in hostname + or any(character in hostname for character in "/@?#\\") + or any(character.isspace() for character in hostname) + ): + raise DeviceGrantError( + f"token host advertised an invalid data-plane hostname: {hostname!r}" + ) if not token.data_plane_hostname_live: raise DeviceGrantError( - f"deployment hostname: {hostname}\n" - "your deployment is not live yet; run `remember login` again when it is" + f"deployment {hostname} is not live yet; " + "run `remember login` again when it is" ) api_url = f"https://{hostname}" return CredentialFile( diff --git a/src/tests/surfaces/test_device_login_contract.py b/src/tests/surfaces/test_device_login_contract.py index 065a7cd5..8b67c20c 100644 --- a/src/tests/surfaces/test_device_login_contract.py +++ b/src/tests/surfaces/test_device_login_contract.py @@ -49,6 +49,16 @@ def test_todays_control_plane_response_parses() -> None: assert parsed.data_plane_hostname_live is True +def test_an_unprovisioned_deployment_may_report_null_status() -> None: + """A nullable hostname may naturally arrive with a nullable live flag.""" + parsed = DeviceTokenSuccess.model_validate( + _control_plane_body(data_plane_hostname=None, data_plane_hostname_live=None) + ) + + assert parsed.data_plane_hostname is None + assert parsed.data_plane_hostname_live is False + + def test_an_expiry_the_server_starts_sending_parses() -> None: """D60 adds `expires_at`; a client must absorb it rather than break.""" expires_at = datetime.now(timezone.utc) + timedelta(days=365) diff --git a/src/tests/surfaces/test_login.py b/src/tests/surfaces/test_login.py index dd68fb6b..71a8b6e6 100644 --- a/src/tests/surfaces/test_login.py +++ b/src/tests/surfaces/test_login.py @@ -493,6 +493,7 @@ def test_login_derives_the_api_url_from_a_live_hostname( ) -> None: """A managed login needs only the token host once its deployment is live.""" _isolate_config(monkeypatch, tmp_path) + monkeypatch.setenv("REMEMBERSTACK_API_URL", "https://stale.example.test") calls: list[str] = [] hostname = f"{_DEPLOYMENT_ID}.dp.remember.dev" _mock_client( @@ -509,6 +510,7 @@ def test_login_derives_the_api_url_from_a_live_hostname( stored = load_credentials() assert stored is not None assert stored.api_url == f"https://{hostname}" + assert calls == ["authorize", "token"] def test_login_api_url_override_wins_over_an_unready_hostname( @@ -532,6 +534,31 @@ def test_login_api_url_override_wins_over_an_unready_hostname( stored = load_credentials() assert stored is not None assert stored.api_url == _API + assert calls == ["authorize", "token"] + + +def test_login_normalizes_a_hostname_and_treats_a_blank_override_as_absent( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Whitespace cannot turn a valid managed endpoint into a broken URL.""" + _isolate_config(monkeypatch, tmp_path) + calls: list[str] = [] + hostname = f"{_DEPLOYMENT_ID}.dp.remember.dev" + _mock_client( + monkeypatch, + _grant_handler( + token_body=_token_body( + data_plane_hostname=f" {hostname} ", data_plane_hostname_live=True + ), + calls=calls, + ), + ) + + assert cli_main(["login", "--token-host", _TOKEN_HOST, "--api-url", " "]) == 0 + stored = load_credentials() + assert stored is not None + assert stored.api_url == f"https://{hostname}" + assert calls == ["authorize", "token"] def test_login_refuses_a_hostname_that_is_not_live( @@ -539,6 +566,8 @@ def test_login_refuses_a_hostname_that_is_not_live( ) -> None: """Do not persist a managed endpoint before the control plane says it is live.""" _isolate_config(monkeypatch, tmp_path) + predecessor = _stored(token_prefix="old-prefix") + write_credentials(credential=predecessor) calls: list[str] = [] hostname = f"{_DEPLOYMENT_ID}.dp.remember.dev" _mock_client( @@ -554,8 +583,34 @@ def test_login_refuses_a_hostname_that_is_not_live( assert cli_main(["login", "--token-host", _TOKEN_HOST]) == 1 captured = capsys.readouterr() assert hostname in captured.err - assert "your deployment is not live yet" in captured.err + assert f"error: deployment {hostname} is not live yet" in captured.err assert "run `remember login` again when it is" in captured.err + stored = load_credentials() + assert stored is not None + assert stored.token_id == predecessor.token_id + assert stored.access_token == predecessor.access_token + assert calls == ["authorize", "token", "revoke"] + + +def test_login_refuses_an_invalid_advertised_hostname( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A token response cannot persist a scheme or path as though it were a host.""" + _isolate_config(monkeypatch, tmp_path) + calls: list[str] = [] + hostname = "https://deployment.example.test/path" + _mock_client( + monkeypatch, + _grant_handler( + token_body=_token_body( + data_plane_hostname=hostname, data_plane_hostname_live=True + ), + calls=calls, + ), + ) + + assert cli_main(["login", "--token-host", _TOKEN_HOST]) == 1 + assert repr(hostname) in capsys.readouterr().err assert load_credentials() is None assert calls == ["authorize", "token", "revoke"] diff --git a/website/src/app/docs/configuration/page.mdx b/website/src/app/docs/configuration/page.mdx index 3ef6ceb0..8f6a9330 100644 --- a/website/src/app/docs/configuration/page.mdx +++ b/website/src/app/docs/configuration/page.mdx @@ -51,7 +51,7 @@ Authoritative template: repository [`.env.example`](https://github.com/writeitai | `REMEMBERSTACK_SELFHOST_REQUIRE_API_AUTH` | When `true`, the API process refuses to start unless it has a perimeter — `API_BEARER_BIND`, `API_SIGNING_KEYS`, or both. Default `false` (open quickstart) | | `REMEMBERSTACK_SELFHOST_TRUSTED_PRINCIPAL_SOURCE` | Whether `X-Ingest-Principal-*` attribution is believed. Default `false`; enable only behind a network perimeter that authenticates the asserted actor. With API auth configured, the presenting credential also needs full `write` authority | | `REMEMBERSTACK_SELFHOST_BROWSER_ORIGINS` | Optional comma-separated `https://` origins allowed to call this deployment from a browser. Empty by default, which advertises no CORS at all. Each entry must be an exact scheme-and-host origin — no wildcard, no path, no `http://` | -| `REMEMBERSTACK_API_URL` | Client target; defaults to `http://127.0.0.1:8000` — **must match** if you change the port | +| `REMEMBERSTACK_API_URL` | SDK and memory-command target; defaults to `http://127.0.0.1:8000` — **must match** if you change the port. `remember login` instead uses its explicit `--api-url` or the live hostname advertised by the token host | | `REMEMBERSTACK_TOKEN_HOST` | Device-grant host for `remember login` (required unless `--token-host` is passed). Never derived from the query API URL | | `REMEMBERSTACK_CONFIG_DIR` | Optional override for the CLI credential directory. Holds `credentials.json` and, while a replaced credential is still awaiting revocation, `pending-revocation.json` — both `0600` inside a `0700` directory | | `REMEMBERSTACK_COST_EXPORT_BIND` | Optional second listen address for HTTP cost export (`127.0.0.1:8001`, `[::1]:8001`, or `unix:/path`). Unset = no HTTP export | diff --git a/website/src/app/docs/project-status/page.mdx b/website/src/app/docs/project-status/page.mdx index 5cd887a1..bc0fe3e3 100644 --- a/website/src/app/docs/project-status/page.mdx +++ b/website/src/app/docs/project-status/page.mdx @@ -156,6 +156,15 @@ Current public release: [`v0.15.0`](https://github.com/writeitai/remember-stack/ The resolver generation moves to `resolver-2026.08g`, so evaluation curves measured under `08f` are not comparable and a fresh run is required. +## What landed after v0.15.0 (on `main`) + +**Managed login uses the deployment hostname it receives.** When the token +host advertises a live data-plane hostname, `remember login` derives the query +API URL, so managed users need only `--token-host`. An explicit `--api-url` +still wins for self-hosted and local deployments. A missing hostname asks for +that flag, while a hostname that is not live prints the name and asks the user +to retry; neither refusal replaces an existing credential. + ## What landed after v0.14.0 (in v0.15.0) **A deployment can tell you what it holds.** `GET /documents` lists document diff --git a/website/src/app/docs/reference/cli/page.mdx b/website/src/app/docs/reference/cli/page.mdx index 7e35cbf6..41621a0d 100644 --- a/website/src/app/docs/reference/cli/page.mdx +++ b/website/src/app/docs/reference/cli/page.mdx @@ -49,7 +49,9 @@ remember login --token-host https://token-host.example For a self-hosted or local deployment, pass its API URL explicitly. This override wins even if the token host advertises a different or not-yet-live -hostname: +hostname. `REMEMBERSTACK_API_URL` remains the default for other client +commands, but it is not a login override: login uses only an explicit +`--api-url` or the hostname bound to the newly minted credential. ```bash remember login --token-host https://token-host.example --api-url http://127.0.0.1:8000 From 432664f3e138b8c4ba4f42f4e2cbd56f965e111f Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 20:22:30 +0200 Subject: [PATCH 3/6] fix(login): harden managed endpoint adoption Validate the advertised endpoint as a real DNS or IP authority, quote it in errors, and warn when an environment URL will override the stored endpoint. Complete the token response design and prove refusal revokes the new bearer. --- ...st_path_metering_and_cost_export_design.md | 18 +++++++- src/rememberstack/surfaces/cli.py | 9 ++++ src/rememberstack/surfaces/device_login.py | 44 ++++++++++++++++--- src/tests/surfaces/test_login.py | 41 ++++++++++++++--- website/src/app/docs/reference/cli/page.mdx | 4 +- 5 files changed, 101 insertions(+), 15 deletions(-) diff --git a/plan/designs/request_path_metering_and_cost_export_design.md b/plan/designs/request_path_metering_and_cost_export_design.md index 2f224a77..934c77df 100644 --- a/plan/designs/request_path_metering_and_cost_export_design.md +++ b/plan/designs/request_path_metering_and_cost_export_design.md @@ -652,6 +652,14 @@ deployment credential to an unrelated endpoint. A self-hosted or local token host that does not advertise a hostname therefore requires the flag. The query API is not the device-grant host. +Without the explicit override, the hostname must be present, structurally +valid, and advertised as live. A missing hostname asks for `--api-url`; an +invalid hostname or a present hostname whose live flag is false or null exits +nonzero and prints the hostname and reason. These checks occur after the token +is minted, so every refusal withdraws the new credential (or keeps its secret +in the pending-revocation journal when withdrawal cannot be confirmed) and +does not write or replace `credentials.json`. + `logout` uses `--token-host`, else `REMEMBERSTACK_TOKEN_HOST`, else the file’s `token_host`. It does not take `--api-url`. @@ -716,10 +724,18 @@ Success **200**: "org_id": "", "deployment_id": "", "label": "", - "token_prefix": "" + "token_prefix": "", + "data_plane_hostname": "", + "data_plane_hostname_live": "" } ``` +The two data-plane fields let login bind the new credential to its deployment. +Older or self-hosted token services may omit them; that is the missing-hostname +case in §6.1. A null live flag is treated as false. The response model ignores +additional fields so the separately deployed token service can evolve without +breaking older clients. + TTL: if authorize’s `expires_in` elapses before 200, stop. Do not keep polling a dead grant. diff --git a/src/rememberstack/surfaces/cli.py b/src/rememberstack/surfaces/cli.py index 8fd79bf1..734fa8fd 100644 --- a/src/rememberstack/surfaces/cli.py +++ b/src/rememberstack/surfaces/cli.py @@ -641,6 +641,7 @@ def _login_locked(args: argparse.Namespace) -> int: """The login itself, with the credential lock already held.""" from rememberstack.surfaces.credentials import append_pending_revocation from rememberstack.surfaces.credentials import assert_revocation_capacity + from rememberstack.surfaces.credentials import CliClientEnv from rememberstack.surfaces.credentials import credential_origin from rememberstack.surfaces.credentials import CredentialError from rememberstack.surfaces.credentials import drop_pending_revocation @@ -852,6 +853,14 @@ def orphaned(payload: object) -> None: print(f"api_url: {credential.api_url}") if credential.expires_at is not None: print(f"expires_at: {credential.expires_at.isoformat()}") + env_api_url = CliClientEnv.model_validate({}).api_url + if env_api_url and env_api_url != credential.api_url: + print( + f"warning: REMEMBERSTACK_API_URL={env_api_url} overrides the " + f"stored api_url {credential.api_url} for other commands; " + "unset it to use this deployment", + file=sys.stderr, + ) return 0 diff --git a/src/rememberstack/surfaces/device_login.py b/src/rememberstack/surfaces/device_login.py index 3dfd5b3f..16e85e41 100644 --- a/src/rememberstack/surfaces/device_login.py +++ b/src/rememberstack/surfaces/device_login.py @@ -9,6 +9,7 @@ from collections.abc import Callable from collections.abc import Mapping from datetime import datetime +from ipaddress import ip_address import time from typing import Annotated from typing import Literal @@ -297,17 +298,13 @@ def credential_from_token( raise DeviceGrantError( "token host did not advertise a data-plane hostname; pass --api-url" ) - if ( - "://" in hostname - or any(character in hostname for character in "/@?#\\") - or any(character.isspace() for character in hostname) - ): + if not _valid_data_plane_hostname(hostname): raise DeviceGrantError( f"token host advertised an invalid data-plane hostname: {hostname!r}" ) if not token.data_plane_hostname_live: raise DeviceGrantError( - f"deployment {hostname} is not live yet; " + f"deployment {hostname!r} is not live yet; " "run `remember login` again when it is" ) api_url = f"https://{hostname}" @@ -326,6 +323,41 @@ def credential_from_token( ) +def _valid_data_plane_hostname(hostname: str) -> bool: + """Accept one printable URL authority with a real DNS or IP host.""" + if not hostname.isascii() or not hostname.isprintable(): + return False + try: + url = httpx.URL(f"https://{hostname}") + except httpx.InvalidURL: + return False + if ( + not url.host + or url.userinfo + or url.path != "/" + or url.query + or url.fragment + or hostname.endswith(":") + or (url.port is not None and not 1 <= url.port <= 65535) + ): + return False + try: + ip_address(url.host) + except ValueError: + if len(url.host) > 253: + return False + labels = url.host.rstrip(".").split(".") + return all( + label + and len(label) <= 63 + and label[0].isalnum() + and label[-1].isalnum() + and all(character.isalnum() or character == "-" for character in label) + for label in labels + ) + return True + + def request_same_origin( *, client: httpx.Client, diff --git a/src/tests/surfaces/test_login.py b/src/tests/surfaces/test_login.py index 71a8b6e6..855571f6 100644 --- a/src/tests/surfaces/test_login.py +++ b/src/tests/surfaces/test_login.py @@ -449,12 +449,19 @@ def _token_body(**overrides: object) -> dict[str, object]: return body -def _grant_handler(*, token_body: dict[str, object], calls: list[str]) -> "object": +def _grant_handler( + *, + token_body: dict[str, object], + calls: list[str], + revoked_authorizations: list[str] | None = None, +) -> "object": """Serve authorize/token/revoke, recording the order they are called in.""" def handler(request: httpx.Request) -> httpx.Response: if request.method == "DELETE": calls.append("revoke") + if revoked_authorizations is not None: + revoked_authorizations.append(request.headers["authorization"]) return httpx.Response(204) if request.url.path == "/v1/device/authorize": calls.append("authorize") @@ -489,7 +496,7 @@ def factory(*args: object, **kwargs: object) -> httpx.Client: def test_login_derives_the_api_url_from_a_live_hostname( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: """A managed login needs only the token host once its deployment is live.""" _isolate_config(monkeypatch, tmp_path) @@ -511,6 +518,11 @@ def test_login_derives_the_api_url_from_a_live_hostname( assert stored is not None assert stored.api_url == f"https://{hostname}" assert calls == ["authorize", "token"] + assert ( + "warning: REMEMBERSTACK_API_URL=https://stale.example.test overrides " + f"the stored api_url https://{hostname} for other commands" + in capsys.readouterr().err + ) def test_login_api_url_override_wins_over_an_unready_hostname( @@ -569,6 +581,7 @@ def test_login_refuses_a_hostname_that_is_not_live( predecessor = _stored(token_prefix="old-prefix") write_credentials(credential=predecessor) calls: list[str] = [] + revoked_authorizations: list[str] = [] hostname = f"{_DEPLOYMENT_ID}.dp.remember.dev" _mock_client( monkeypatch, @@ -577,28 +590,42 @@ def test_login_refuses_a_hostname_that_is_not_live( data_plane_hostname=hostname, data_plane_hostname_live=False ), calls=calls, + revoked_authorizations=revoked_authorizations, ), ) assert cli_main(["login", "--token-host", _TOKEN_HOST]) == 1 captured = capsys.readouterr() assert hostname in captured.err - assert f"error: deployment {hostname} is not live yet" in captured.err + assert f"error: deployment {hostname!r} is not live yet" in captured.err assert "run `remember login` again when it is" in captured.err stored = load_credentials() assert stored is not None assert stored.token_id == predecessor.token_id assert stored.access_token == predecessor.access_token assert calls == ["authorize", "token", "revoke"] - - + assert revoked_authorizations == [f"Bearer {_ACCESS}-new"] + + +@pytest.mark.parametrize( + "hostname", + [ + "https://deployment.example.test/path", + ":8443", + "[::1", + "evil\x1b[2m.example", + "a\x00b", + ], +) def test_login_refuses_an_invalid_advertised_hostname( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + hostname: str, ) -> None: """A token response cannot persist a scheme or path as though it were a host.""" _isolate_config(monkeypatch, tmp_path) calls: list[str] = [] - hostname = "https://deployment.example.test/path" _mock_client( monkeypatch, _grant_handler( diff --git a/website/src/app/docs/reference/cli/page.mdx b/website/src/app/docs/reference/cli/page.mdx index 41621a0d..5ed13487 100644 --- a/website/src/app/docs/reference/cli/page.mdx +++ b/website/src/app/docs/reference/cli/page.mdx @@ -51,7 +51,9 @@ For a self-hosted or local deployment, pass its API URL explicitly. This override wins even if the token host advertises a different or not-yet-live hostname. `REMEMBERSTACK_API_URL` remains the default for other client commands, but it is not a login override: login uses only an explicit -`--api-url` or the hostname bound to the newly minted credential. +`--api-url` or the hostname bound to the newly minted credential. When that +environment variable points somewhere else, a successful login warns that it +will override the stored deployment URL for subsequent commands. ```bash remember login --token-host https://token-host.example --api-url http://127.0.0.1:8000 From 8b5156cdcd4d2e3717b515de1342e09d9876bec5 Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 20:31:58 +0200 Subject: [PATCH 4/6] fix(login): contain lazy hostname validation failures Guard httpx's lazy IDNA decoding so every malformed advertised host becomes a normal adoption refusal and the freshly minted credential is withdrawn. Record all hostname refusal outcomes in the normative recovery table. --- ...st_path_metering_and_cost_export_design.md | 3 +++ src/rememberstack/surfaces/device_login.py | 26 ++++++++++++------- src/tests/surfaces/test_login.py | 1 + 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/plan/designs/request_path_metering_and_cost_export_design.md b/plan/designs/request_path_metering_and_cost_export_design.md index 934c77df..273a61ed 100644 --- a/plan/designs/request_path_metering_and_cost_export_design.md +++ b/plan/designs/request_path_metering_and_cost_export_design.md @@ -821,6 +821,9 @@ read if the platform reports a world-readable mode. | Export cursor malformed | 422, no receipts | | Empty page inside horizon | 200 heartbeat | | Login without `--token-host` / env | Exit 2; no derive-from-api-url | +| Login without `--api-url` or an advertised hostname | Exit 1; ask for `--api-url`; withdraw the mint; keep any existing file | +| Login with an invalid advertised hostname | Exit 1; print the hostname; withdraw the mint; keep any existing file | +| Login with a hostname that is not live | Exit 1; print the hostname; withdraw the mint; keep any existing file | | Logout revoke 5xx | Keep file; exit 1 | | Credential file world-readable | Refuse to read | diff --git a/src/rememberstack/surfaces/device_login.py b/src/rememberstack/surfaces/device_login.py index 16e85e41..6bc15860 100644 --- a/src/rememberstack/surfaces/device_login.py +++ b/src/rememberstack/surfaces/device_login.py @@ -329,24 +329,30 @@ def _valid_data_plane_hostname(hostname: str) -> bool: return False try: url = httpx.URL(f"https://{hostname}") - except httpx.InvalidURL: + host = url.host + port = url.port + userinfo = url.userinfo + path = url.path + query = url.query + fragment = url.fragment + except (httpx.InvalidURL, UnicodeError): return False if ( - not url.host - or url.userinfo - or url.path != "/" - or url.query - or url.fragment + not host + or userinfo + or path != "/" + or query + or fragment or hostname.endswith(":") - or (url.port is not None and not 1 <= url.port <= 65535) + or (port is not None and not 1 <= port <= 65535) ): return False try: - ip_address(url.host) + ip_address(host) except ValueError: - if len(url.host) > 253: + if len(host) > 253: return False - labels = url.host.rstrip(".").split(".") + labels = host.rstrip(".").split(".") return all( label and len(label) <= 63 diff --git a/src/tests/surfaces/test_login.py b/src/tests/surfaces/test_login.py index 855571f6..29760cb6 100644 --- a/src/tests/surfaces/test_login.py +++ b/src/tests/surfaces/test_login.py @@ -615,6 +615,7 @@ def test_login_refuses_a_hostname_that_is_not_live( "[::1", "evil\x1b[2m.example", "a\x00b", + "xn--0.com", ], ) def test_login_refuses_an_invalid_advertised_hostname( From cdaea6f3fa4781d3fa67b4992e7fba542788ebd2 Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 20:43:26 +0200 Subject: [PATCH 5/6] fix(login): align managed host validation with URL reality Accept underscore labels and one DNS root dot while refusing a second, document the exact authority shape, and tell users how to override an invalid advertised host. --- ...est_path_metering_and_cost_export_design.md | 9 +++++++-- src/rememberstack/surfaces/device_login.py | 18 ++++++++++++------ src/tests/surfaces/test_login.py | 7 +++++-- website/src/app/docs/reference/cli/page.mdx | 3 ++- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/plan/designs/request_path_metering_and_cost_export_design.md b/plan/designs/request_path_metering_and_cost_export_design.md index 273a61ed..4f8792c1 100644 --- a/plan/designs/request_path_metering_and_cost_export_design.md +++ b/plan/designs/request_path_metering_and_cost_export_design.md @@ -653,7 +653,12 @@ host that does not advertise a hostname therefore requires the flag. The query API is not the device-grant host. Without the explicit override, the hostname must be present, structurally -valid, and advertised as live. A missing hostname asks for `--api-url`; an +valid, and advertised as live. Concretely, it is one printable ASCII host or +`host:port`, with no scheme, path, query, fragment, userinfo, or whitespace; a +port is 1..65535, and the host is either an IP literal or nonempty DNS labels +of at most 63 characters and 253 characters in total. Underscore labels and +one trailing DNS root dot are accepted; an empty label, including one left by +a second trailing dot, is not. A missing hostname asks for `--api-url`; an invalid hostname or a present hostname whose live flag is false or null exits nonzero and prints the hostname and reason. These checks occur after the token is minted, so every refusal withdraws the new credential (or keeps its secret @@ -822,7 +827,7 @@ read if the platform reports a world-readable mode. | Empty page inside horizon | 200 heartbeat | | Login without `--token-host` / env | Exit 2; no derive-from-api-url | | Login without `--api-url` or an advertised hostname | Exit 1; ask for `--api-url`; withdraw the mint; keep any existing file | -| Login with an invalid advertised hostname | Exit 1; print the hostname; withdraw the mint; keep any existing file | +| Login with an invalid advertised hostname | Exit 1; print the hostname; ask for `--api-url`; withdraw the mint; keep any existing file | | Login with a hostname that is not live | Exit 1; print the hostname; withdraw the mint; keep any existing file | | Logout revoke 5xx | Keep file; exit 1 | | Credential file world-readable | Refuse to read | diff --git a/src/rememberstack/surfaces/device_login.py b/src/rememberstack/surfaces/device_login.py index 6bc15860..4c761cb6 100644 --- a/src/rememberstack/surfaces/device_login.py +++ b/src/rememberstack/surfaces/device_login.py @@ -300,7 +300,8 @@ def credential_from_token( ) if not _valid_data_plane_hostname(hostname): raise DeviceGrantError( - f"token host advertised an invalid data-plane hostname: {hostname!r}" + "token host advertised an invalid data-plane hostname: " + f"{hostname!r}; pass --api-url to override" ) if not token.data_plane_hostname_live: raise DeviceGrantError( @@ -325,7 +326,11 @@ def credential_from_token( def _valid_data_plane_hostname(hostname: str) -> bool: """Accept one printable URL authority with a real DNS or IP host.""" - if not hostname.isascii() or not hostname.isprintable(): + if ( + not hostname.isascii() + or not hostname.isprintable() + or any(character.isspace() for character in hostname) + ): return False try: url = httpx.URL(f"https://{hostname}") @@ -352,13 +357,14 @@ def _valid_data_plane_hostname(hostname: str) -> bool: except ValueError: if len(host) > 253: return False - labels = host.rstrip(".").split(".") + stem = host[:-1] if host.endswith(".") else host + labels = stem.split(".") return all( label and len(label) <= 63 - and label[0].isalnum() - and label[-1].isalnum() - and all(character.isalnum() or character == "-" for character in label) + and (label[0].isalnum() or label[0] == "_") + and (label[-1].isalnum() or label[-1] == "_") + and all(character.isalnum() or character in "-_" for character in label) for label in labels ) return True diff --git a/src/tests/surfaces/test_login.py b/src/tests/surfaces/test_login.py index 29760cb6..8312ae1d 100644 --- a/src/tests/surfaces/test_login.py +++ b/src/tests/surfaces/test_login.py @@ -555,7 +555,7 @@ def test_login_normalizes_a_hostname_and_treats_a_blank_override_as_absent( """Whitespace cannot turn a valid managed endpoint into a broken URL.""" _isolate_config(monkeypatch, tmp_path) calls: list[str] = [] - hostname = f"{_DEPLOYMENT_ID}.dp.remember.dev" + hostname = f"svc_1.{_DEPLOYMENT_ID}.dp.remember.dev." _mock_client( monkeypatch, _grant_handler( @@ -616,6 +616,7 @@ def test_login_refuses_a_hostname_that_is_not_live( "evil\x1b[2m.example", "a\x00b", "xn--0.com", + "deployment.example.test..", ], ) def test_login_refuses_an_invalid_advertised_hostname( @@ -638,7 +639,9 @@ def test_login_refuses_an_invalid_advertised_hostname( ) assert cli_main(["login", "--token-host", _TOKEN_HOST]) == 1 - assert repr(hostname) in capsys.readouterr().err + captured = capsys.readouterr() + assert repr(hostname) in captured.err + assert "pass --api-url to override" in captured.err assert load_credentials() is None assert calls == ["authorize", "token", "revoke"] diff --git a/website/src/app/docs/reference/cli/page.mdx b/website/src/app/docs/reference/cli/page.mdx index 5ed13487..70394edf 100644 --- a/website/src/app/docs/reference/cli/page.mdx +++ b/website/src/app/docs/reference/cli/page.mdx @@ -63,7 +63,8 @@ remember logout Without `--api-url`, login refuses an advertised hostname that is not live, prints that hostname, and asks you to run `remember login` again when it is. If the token host advertises no hostname, login asks for `--api-url`. Neither -refusal writes a credential file. +refusal writes a credential file. An advertised value that is not a plain host +or `host:port` is also refused; pass `--api-url` to override it. `login` prints the user code and both verification URLs, then polls `POST {token_host}/v1/device/token`. It never prints `device_code` or From 2c7d319616ec2f191857d2066a7d4a20a77f0e67 Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Thu, 3 Sep 2026 20:53:44 +0200 Subject: [PATCH 6/6] fix(login): reject ambiguous authority spellings Reject empty-userinfo and trailing-path forms that URL parsing normalizes away, cover both in the refusal suite, and keep the adoption recovery comment exact. --- src/rememberstack/surfaces/cli.py | 7 ++++--- src/rememberstack/surfaces/device_login.py | 2 ++ src/tests/surfaces/test_login.py | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/rememberstack/surfaces/cli.py b/src/rememberstack/surfaces/cli.py index 734fa8fd..b893ea44 100644 --- a/src/rememberstack/surfaces/cli.py +++ b/src/rememberstack/surfaces/cli.py @@ -820,9 +820,10 @@ def orphaned(payload: object) -> None: ) ) except BaseException: - # The journal entry stays: whatever went wrong, the credential - # exists at the token host and the next login or logout will - # retire it. Nothing here has to succeed for that to hold. + # Unless the hostname-refusal path above already retired the + # mint, its journal entry stays: the credential exists at the + # token host and the next login or logout will retire it. + # Nothing here has to succeed for that to hold. raise except KeyboardInterrupt: return 130 diff --git a/src/rememberstack/surfaces/device_login.py b/src/rememberstack/surfaces/device_login.py index 4c761cb6..0a96b9ae 100644 --- a/src/rememberstack/surfaces/device_login.py +++ b/src/rememberstack/surfaces/device_login.py @@ -348,6 +348,8 @@ def _valid_data_plane_hostname(hostname: str) -> bool: or path != "/" or query or fragment + or "@" in hostname + or "/" in hostname or hostname.endswith(":") or (port is not None and not 1 <= port <= 65535) ): diff --git a/src/tests/surfaces/test_login.py b/src/tests/surfaces/test_login.py index 8312ae1d..dc996907 100644 --- a/src/tests/surfaces/test_login.py +++ b/src/tests/surfaces/test_login.py @@ -611,6 +611,8 @@ def test_login_refuses_a_hostname_that_is_not_live( "hostname", [ "https://deployment.example.test/path", + "@deployment.example.test", + "deployment.example.test:8443/", ":8443", "[::1", "evil\x1b[2m.example",