Skip to content

feature(Auth): Sign in with aperiodic.login() and find the key on its own - #43

Merged
szemyd merged 4 commits into
mainfrom
claude/aperiodic-login
Nov 10, 2026
Merged

szemyd merged 4 commits into
mainfrom
claude/aperiodic-login

Conversation

@szemyd

@szemyd szemyd commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Needs the server side first: dream-faster/unravel-router#1174 (the /api/v1/cli/* routes and /device) is merged; release this after it's live in production. It's the same flow as aperiodic-io/cli#30, and both write the same credentials file. Until the server is deployed, login() gets a 404 when starting, and everything else works as before.

What changes

  • aperiodic.login(), or python -m aperiodic login on a machine without the CLI:
    • If this machine already has a saved key that still works, it says Already signed in as "<name>" on <plan>. Call aperiodic.login(force=True) to replace it. and returns that login. On the command line the flag is --force. Logging in twice doesn't pile up keys.
    • Otherwise it starts a login, prints the aperiodic.io/device?code=… link and the code, and polls until the person allows it.
      • It backs off on slow_down and stops on access_denied / expired_token.
      • It keeps polling through 5xx, 429 and dropped connections.
    • It saves the machine's own named key to ~/.config/aperiodic/credentials. The file is written through a temp file at mode 0600, with the same path rules as the CLI ($APERIODIC_CONFIG_DIR, $XDG_CONFIG_HOME, %APPDATA%). When it replaced a key, it revokes the old one (best effort).
    • It opens a browser only on a desktop: not over SSH, in a notebook (the kernel may be remote) or on Linux without a display.
    • It returns LoginResult(key_name, plan_name, path). The key itself is never printed or returned, so it can't end up in a notebook's output.
  • Key lookup: a passed api_key= first, then APERIODIC_API_KEY, then the demo key for preview=True, then the saved key. A saved key never answers a preview request, so a revoked one can't break the free sample. This applies to every endpoint, including stream(), get_symbols() and get_symbols_async(), which now take api_key=None. The README spells out the order, and the CLI uses the same one.
  • A rejected saved key raises The key saved in <path> was rejected (revoked?). Run aperiodic.login() again. instead of a bare 401.
  • aperiodic.logout() revokes the key (DELETE /cli/key), removes the file and returns LogoutResult(removed, revoked, message). The message says what happened: revoked, already revoked, or the account's default key (rotate it at /home/api).
  • aperiodic.whoami() returns the key and what its plan covers. python -m aperiodic whoami prints it, with a Markets line for a plan held to some (Free: "BTC, ETH, SOL and 10 RWA perps").
  • Errors:
    • With no key, the error says to run aperiodic.login() or set APERIODIC_API_KEY.
    • Offline, the command line prints Error: Could not reach … instead of a traceback.
  • Standard library only (urllib): the core install has no HTTP client (httpx is an extra). In Pyodide, login() explains it needs regular Python.

Tests

  • tests/test_auth.py runs against a local ThreadingHTTPServer stand-in of the server and covers:
    • login saves the key with mode 0600 and never prints or returns it; a stale temp file can't loosen the mode;
    • re-login: a working key is kept, force=True replaces and revokes, and a revoked key is replaced;
    • a denial saves nothing, and polling backs off on slow_down and survives 5xx / 429 / non-JSON pages;
    • offline login / whoami fail with a message, not a traceback;
    • lookup order: passed key, then env, then the saved key, with no key leaking in from the machine running the tests;
    • preview stays on the demo key;
    • a rejected saved key names the file and says to log in again;
    • logout revokes and removes the key and says what happened in one line;
    • get_symbols (sync and async) finds the saved key;
    • whoami and the python -m aperiodic entry point, including the Markets line and --force;
    • the new functions are exported.
  • tests/test_stream.py: stream() uses the saved key when none is passed, and a rejected saved key says to log in again.
  • test_auth, test_readme and test_stream pass (89). ruff 0.14.1 (CI's version) is clean.
  • The full suite fails the same 34 integration tests here as on main (they need a real key and network).

🤖 Generated with Claude Code

https://claude.ai/code/session_01MoDcuvZ8WqH4mnxq31Muur

… own

- aperiodic.login() (or python -m aperiodic login) signs in through
  aperiodic.io/device, on a laptop, over SSH or in a remote notebook,
  and saves the machine's own key where the Aperiodic CLI saves its key.
- Calls without api_key= now take APERIODIC_API_KEY, then the saved key;
  preview calls stay on the demo key. The missing-key error says how to
  sign in.
- aperiodic.logout() revokes and removes the key; aperiodic.whoami()
  shows the key and what the plan covers.
- Standard library only, so it works on a bare install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoDcuvZ8WqH4mnxq31Muur
… hermetic

Ruff 0.14.1 (CI's) wants __all__ and the stream module's imports sorted.

The test that a call with no key raises now clears APERIODIC_API_KEY and
points the saved-key lookup at an empty directory: both are key sources
since aperiodic.login(), and CI sets the former.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoDcuvZ8WqH4mnxq31Muur
…he sign-in copy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoDcuvZ8WqH4mnxq31Muur

szemyd commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Critical review: please address before merge

I checked this against the server routes in dream-faster/unravel-router#1174 (cli/login.ts, cli/token.ts, cli/key.ts, cli/whoami.ts, lib/cli-login.server.ts) and ran tests/test_auth.py (8 pass) and TestResolveApiKey (5 pass). The device flow matches the server contract. The problems are in what happens around it.

1. stream() and get_symbols() still require api_key (bug: the README and PR body say otherwise)

The PR says "stream() takes the same fallback", and the README says "calls find the key on their own, so api_key= can be left out". Neither is true for these two functions:

>>> aperiodic.get_symbols(exchange="binance-futures")
TypeError: get_symbols() missing 1 required positional argument: 'api_key'
>>> aperiodic.stream(dataset="flow", exchange="binance-futures", interval="1m", symbols=[...])
TypeError: stream() missing 1 required positional argument: 'api_key'
  • src/aperiodic/endpoints/stream.py:492: api_key: str has no default, so the api_key or default_api_key() at :574 only runs when someone passes None or "" explicitly.
  • src/aperiodic/endpoints/symbols.py:8 and :50: get_symbols / get_symbols_async never resolve a key at all. The CLI's symbols command does.

Fix: use api_key: str | None = None on stream, get_symbols and get_symbols_async (it's already the first parameter, so positional callers keep working). Send get_symbols through _resolve_api_key(api_key, preview=False). Add a test that calls each one with no api_key and a saved key in APERIODIC_CONFIG_DIR. That test would have caught this.

2. Logging in again leaks a key each time (affects the CLI too)

login() mints a new named key and overwrites credentials without looking at what was there (auth.py:139 → _save_credentials). The old key stays live on the server and nothing on this machine refers to it any more. Two everyday ways to hit it:

  • run aperiodic.login() twice, for example once in each notebook;
  • use the CLI and Python on the same machine. Each tool's login overwrites the other's key.

The server caps an account at MAX_NAMED_KEYS = 25 (named-api-keys.server.ts). After that, login fails with 409 key_limit, and the user has to clean up by hand a page full of identically named " (aperiodic Python)" keys.

Fix (same in both clients): before starting, if a saved key exists, either
(a) check it with /cli/whoami, and if it's valid, say "Already signed in as X; login(force=True) to replace it" and return; or
(b) after the new key is saved, make a best-effort DELETE /cli/key with the old key.

I'd do (a) with (b) as the force path.

3. A revoked saved key fails with a bare 401 and no way forward

Revocable per-machine keys are the whole point of this flow, so "my saved key was revoked on /home/api" will happen. Right now the user gets the generic APIError 401, which doesn't mention the credentials file or login(). When the key came from the file (track that in _resolve_api_key / default_api_key), turn 401 into: "The key saved in <path> was rejected (revoked?). Run aperiodic.login() again."

4. The poll loop gives up on transient errors and leaks tracebacks

  • auth.py:176-216: only OSError is retried. A Cloudflare 502/520 HTML page comes back as (502, {}), and the loop then raises Login failed: 502, ending a wait of up to 10 minutes on one blip. The CLI keeps polling in the same case. Treat 5xx and 429 like authorization_pending.
  • A 200 with a non-JSON body (captive portal, CF challenge) raises an uncaught JSONDecodeError out of _request.
  • The first _request("POST", "/cli/login") (:160) and whoami() don't catch URLError. __main__.py only catches AperiodicDataError, so python -m aperiodic login / whoami print a traceback when offline. Wrap network errors in AperiodicDataError inside _request.

5. python -m aperiodic logout contradicts itself when revocation fails

logout() prints "Removed the key from this machine. Revoke it at … too." and then returns True, so __main__.py:35 prints "Signed out." on the next line. Return a small result (removed, revoked) or an enum and print one accurate message. While you're there, pass on the server's reason: 400 not_a_named_key means "rotate your default key", and a 401 means it's already revoked.

6. Preview precedence differs from the CLI

Here preview=True uses the demo key even when a key is saved (utils.py:42). In aperiodic-io/cli#30, --preview uses the saved key ahead of the demo key. Pick one rule for both. I'd suggest: explicit key, then env, then demo for preview, then the saved key. With that order, a revoked saved key can never break the free sample. This PR already does that, so the CLI is the one to change. Whichever you pick, write it down in both READMEs.

Nits

  • login(..., sleep=time.sleep) puts a test seam into the public signature. Patch time.sleep in the test with monkeypatch instead.
  • _save_credentials uses a fixed credentials.tmp. Two logins at once race, and if a stale .tmp already exists, os.open(..., 0o600) keeps its old mode. Use tempfile.mkstemp(dir=path.parent), which is 0600 by default, then os.replace.
  • whoami() raises answer.get("error") (invalid_api_key-style codes). Prefer error_description when it's there.

Fine as is

Standard-library-only HTTP, never printing or returning the key, the notebook and SSH browser detection, os.replace for the atomic write, and the RFC 8628 handling (slow_down +5s, access_denied, expired_token) all look right. I checked cross-compatibility: a file written by Python is read correctly by the Go CLI.


Generated by Claude Code

…tream and get_symbols, clearer errors

- login() keeps a saved key the server still accepts and says so;
  login(force=True) (python -m aperiodic login --force) makes a new key
  and revokes the one it replaces, as does logging in after the saved key
  was rejected.
- stream(), get_symbols() and get_symbols_async() find the key on their
  own like every other call.
- A 401 for the key saved on this machine names its file and says to run
  aperiodic.login() again, in REST calls, the stream handshake and whoami.
- Polling rides out 5xx, 429 and dropped connections; non-JSON bodies and
  network errors become AperiodicDataError, so the command line never
  prints a traceback offline.
- logout() returns a LogoutResult saying whether the key was removed and
  revoked, and the command line prints one accurate line.
- The key order is written down in the README: passed key, env, demo key
  for preview, saved key. The credentials file is written via mkstemp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoDcuvZ8WqH4mnxq31Muur

szemyd commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Every point is addressed in 18e81f1:

  1. stream(), get_symbols(), get_symbols_async(): they take api_key: str | None = None and find the key on their own; get_symbols goes through _resolve_api_key(api_key, preview=False). Python doesn't allow a required parameter after one with a default, so the parameters after api_key (such as exchange) now default to None too and raise a clear TypeError when missing. Positional calls like get_symbols(key, "binance-futures") keep working. Tests: test_get_symbols_finds_the_saved_key (sync and async) and test_stream_uses_the_saved_key_when_none_is_passed (reads the X-API-KEY header on the fake stream server).
  2. Re-login leak: login(force=False) first checks the saved key with /cli/whoami. If it works, it prints Already signed in as "<name>" on <plan>. Call aperiodic.login(force=True) to replace it. and returns that login. With force=True, or when the saved key is rejected, it runs the device flow, then makes a best-effort DELETE /cli/key with the old key. python -m aperiodic login --force names its own flag. The CLI follows the same rule (feature(Auth): Add aperiodic login, logout and whoami with a saved per-machine key cli#30). Tests: test_login_again_with_a_key_that_still_works_keeps_it, test_force_login_saves_a_new_key_and_revokes_the_old_one, test_login_after_the_saved_key_was_revoked_starts_over, test_command_line_login_names_its_own_force_flag.
  3. Revoked saved key: a 401 now says The key saved in <path> was rejected (revoked?). Run aperiodic.login() again. This covers both HTTP transports, the stream handshake and whoami(). Keys passed in or from the env keep today's error. The client tells a saved key apart by comparing it with the file's key, rather than threading a source flag through every call. Tests: test_a_rejected_saved_key_says_where_it_is_and_to_log_in_again and test_a_rejected_saved_key_says_to_log_in_again (stream).
  4. Polling and offline:
    • Polling treats 5xx and 429 like authorization_pending.
    • A body that isn't JSON reads as {} instead of raising.
    • Network errors become an AperiodicDataError subclass: polling retries them, and python -m aperiodic login / whoami print Error: Could not reach … instead of a traceback.
    • Tests: test_login_keeps_polling_through_a_proxy_error_page_and_a_rate_limit (502 HTML, 429, 520), test_a_page_that_is_not_json_reads_as_an_empty_answer, test_offline_login_and_whoami_fail_with_a_message_not_a_traceback.
  5. logout(): returns LogoutResult(removed, revoked, message), and the command line prints that one accurate line. A default key says to rotate it at /home/api, and a 401 or revoked: false says it was already revoked. Tests: test_logout_says_what_happened_to_the_key_in_one_line (four cases).
  6. Key order: the README now spells it out: passed key, then APERIODIC_API_KEY, then DEMO-KEY for preview=True, then the saved key. The CLI was changed to match.

Nits:

  • sleep=: gone from login(); the tests patch time.sleep instead.
  • _save_credentials: writes through mkstemp and os.replace. Test: test_saving_never_reuses_a_stale_temporary_file.
  • whoami() errors: it prefers error_description. Test: test_whoami_prefers_the_servers_explanation_to_its_error_code.

ruff 0.14.1 (CI's version) is clean. test_auth, test_readme and test_stream pass (89). The full suite shows the same 34 integration failures as main, which need a real key and network.


Generated by Claude Code

szemyd added a commit to aperiodic-io/cli that referenced this pull request Nov 10, 2026
…r-machine key (#30)

**Needs the server side first:**
[dream-faster/unravel-router#1174](dream-faster/unravel-router#1174)
(the `/api/v1/cli/*` routes and the `/device` page) is merged; release
this after it's live in production. Until then `aperiodic login` gets a
404 from `/cli/login`, and every other command works as before.

## What changes

- **`aperiodic login`:**
- If this machine already has a saved key that still works, it says
`Already signed in as "<name>" on <plan>. Run 'aperiodic login --force'
to replace it.` and stops. Logging in twice doesn't pile up keys.
- Otherwise it starts a login and opens `aperiodic.io/device?code=…` in
the browser. It only opens an https link on the API's own host; anything
else is printed. Over SSH (`SSH_CONNECTION` / `SSH_TTY`), on Linux
without a display, or with `--no-browser`, it prints the link to open on
any device instead.
- It shows the code to check and polls until the person allows it,
backing off on `slow_down` and stopping on `access_denied` /
`expired_token`. Then it saves the machine's own named key, and when it
replaced one, revokes the old one (best effort).
- `--name` overrides the key's name, which defaults to `<hostname>
(aperiodic CLI)`.
- **Credentials file:** `~/.config/aperiodic/credentials`, JSON, mode
0600 in a 0700 directory, written through a temp file and renamed into
place. It lives in `$XDG_CONFIG_HOME/aperiodic`, or
`%APPDATA%\aperiodic` on Windows; `$APERIODIC_CONFIG_DIR` overrides
both. Only a missing `api_key` counts as no credentials. The Python
client reads the same file
([aperiodic-io/client#43](aperiodic-io/client#43)).
- **Key lookup for every command** (`metric`, `symbols`, `raw`,
`stream`): `APERIODIC_API_KEY` first, then `DEMO-KEY` with `--preview`,
then the saved key. A saved key never answers a preview request, so a
revoked one can't break the free sample. The order is written down in
the README, and the Python client uses the same one.
- **A rejected saved key** says `The key saved in <path> was rejected
(revoked?). Run 'aperiodic login' again.` instead of a bare 401.
- **`aperiodic logout`:** revokes this machine's key on the server
(`DELETE /cli/key`) and deletes the file. It passes on the server's
reason when revoking doesn't happen: for an account's default key it
says to rotate it at /home/api, and for a key that's already revoked it
says so. If the server can't be reached, it still removes the file and
says where to revoke the key by hand.
- **`aperiodic whoami`:** shows the plan, which key is in use and where
it came from, datasets in full, markets (for a plan held to some, as
Free is: "BTC, ETH, SOL and 10 RWA perps"), history and live.
- Usage text and README updated.

## Tests

- `cli_auth_test.go` runs against an `httptest` stand-in of the server
and covers:
- login: it opens only safe links, shows the code, polls until the key
arrives, and saves it with mode 0600 (checked where the OS has file
modes, so not on Windows);
- re-login: a working key is kept, `--force` replaces and revokes, and a
revoked key is replaced;
  - over SSH it prints the link and opens nothing;
  - it backs off on `slow_down`, and a denial saves nothing;
- `APERIODIC_API_KEY` wins over the saved key, and `--preview` uses
`DEMO-KEY` even with a saved key;
  - a revoked saved key gets the "log in again" message;
- the credentials file tolerates any `created_at`, and saving leaves no
temp file behind;
  - logout revokes and removes the key, and reports the server's reason;
  - `whoami` output, including the markets line and the demo key;
  - `login -h` exits 0;
  - with no key at all, the error points to `aperiodic login`.
- `gofmt` and `go vet` (also with `GOOS=windows`) are clean. Every test
that needs no live key passes.
- The 8 live tests (`requireAPIKey`) fail locally without
`APERIODIC_API_KEY`, as they do on `main`. CI sets the key.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01MoDcuvZ8WqH4mnxq31Muur

---------

Co-authored-by: Claude <noreply@anthropic.com>
@szemyd
szemyd merged commit 85aa2f0 into main Nov 10, 2026
20 of 21 checks passed
@szemyd
szemyd deleted the claude/aperiodic-login branch November 10, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants