Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions plan/designs/request_path_metering_and_cost_export_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -644,9 +644,26 @@ 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.

Without the explicit override, the hostname must be present, structurally
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
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`.
Expand Down Expand Up @@ -712,10 +729,18 @@ Success **200**:
"org_id": "<uuid>",
"deployment_id": "<uuid>",
"label": "<string>",
"token_prefix": "<string>"
"token_prefix": "<string>",
"data_plane_hostname": "<hostname or null>",
"data_plane_hostname_live": "<boolean or null>"
}
```

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.

Expand Down Expand Up @@ -801,6 +826,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; 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 |

Expand Down
34 changes: 26 additions & 8 deletions src/rememberstack/surfaces/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,8 +654,10 @@ 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"
# 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)
except ValueError as error:
Expand Down Expand Up @@ -762,9 +764,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
Expand Down Expand Up @@ -811,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
Expand Down Expand Up @@ -844,6 +854,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


Expand Down
83 changes: 77 additions & 6 deletions src/rememberstack/surfaces/device_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@
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
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
Expand Down Expand Up @@ -67,9 +70,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)
Expand All @@ -85,7 +88,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.
Expand Down Expand Up @@ -283,9 +288,27 @@ 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."""
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 not _valid_data_plane_hostname(hostname):
raise DeviceGrantError(
"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(
f"deployment {hostname!r} is not live yet; "
"run `remember login` again when it is"
)
api_url = f"https://{hostname}"
return CredentialFile(
version=1,
api_url=api_url,
Expand All @@ -301,6 +324,54 @@ 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()
or any(character.isspace() for character in hostname)
):
return False
try:
url = httpx.URL(f"https://{hostname}")
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 host
or userinfo
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)
):
return False
try:
ip_address(host)
except ValueError:
if len(host) > 253:
return False
stem = host[:-1] if host.endswith(".") else host
labels = stem.split(".")
return all(
label
and len(label) <= 63
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


def request_same_origin(
*,
client: httpx.Client,
Expand Down
10 changes: 10 additions & 0 deletions src/tests/surfaces/test_device_login_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading