Skip to content

feat(cli): lazy-resolve operator_id via billing GET /api/v1/me - #3

Closed
CryptoFewka wants to merge 2 commits into
mainfrom
feat/lazy-operator-id
Closed

feat(cli): lazy-resolve operator_id via billing GET /api/v1/me#3
CryptoFewka wants to merge 2 commits into
mainfrom
feat/lazy-operator-id

Conversation

@CryptoFewka

Copy link
Copy Markdown
Contributor

Summary

  • KEYSYNC_OPERATOR_ID becomes optional. When omitted, the CLI calls GET /api/v1/me on first contact with billing and uses the returned operator_id for subsequent URL paths. Explicit env or flag still wins, so existing customer configs keep working.
  • New BillingClient.get_me() reuses the existing tenacity/401-mapping _call. An ops-staff credential (empty operator_id in the response) is treated as a terminal BillingError with an actionable message.
  • Fixes doc rot: operator_id was called an "operator UUID" in the README and example values were full UUID strings. It's actually TEXT holding a stringified auto-increment integer (per optimum-billing migration 000011_integer_operator_id). Example placeholders now use "42".
  • pyproject.toml description rewritten — the old text still referenced the long-dead JWT-exchange model.

Dependency

Requires GET /api/v1/me to be deployed on whatever billing host KEYSYNC_BILLING_URL points at. See getoptimum/optimum-billing#71. CI is unaffected — respx stubs /me in the test suite.

Test plan

  • tests/test_billing_client.pyget_me happy path + bearer forwarding, empty-operator → BillingError, 401 → BillingError
  • tests/test_config.py — missing operator_id now accepted (was rejected); explicit value still preserved
  • tests/test_cli_lazy_me.py — end-to-end CliRunner: keysync show without --operator-id calls /me once then the validator list endpoint with the resolved id; explicit --operator-id skips /me entirely (verified by stubbing /me with 500 — never called)
  • Full suite: 49/49 passed, ruff check clean, mypy src/ clean
  • Live smoke after billing PR deploys: unset KEYSYNC_OPERATOR_ID && keysync show should log "operator_id not configured; resolving via billing /me" and then the JSON

Written with Claude Code

keysync was originally designed around a JWT-exchange flow: POST the
ovi_live_* to optimum-auth, decode the resulting JWT for operator_id,
present that JWT as the Bearer on every billing call, re-exchange
before expiry, handle `scope_changed` mid-reconcile.

Reviewing the design against keysync's actual workload — one client
per operator, hourly cron, ~4 billing calls per reconcile — the
exchange pattern's wins (stateless-verify-at-scale, OAuth2-shaped
ecosystem fit, scoped capability tokens) don't apply. Meanwhile its
costs are real: multi-issuer JWT verification in optimum-billing,
operator-key dispatch in optimum-auth, scope_version bump on revoke
to invalidate outstanding JWTs, ~150 lines of CLI plumbing here.

Billing's `combinedAuth` already accepts `Authorization: Bearer
ovi_live_…` directly: HMAC-hashes per request, looks up
`internal.operator_api_keys`, stamps role=operator. Revoking the key
at the dashboard takes effect on the next request (no 1h cache
window). So the direct path is materially simpler AND has faster
revocation.

What changes here:

- `AuthClient` collapses to a credential holder. No httpx, no
  tenacity, no jwt.decode, no cache window, no scope_changed retry.
  Just (api_key, operator_id) returned on every token() call.
- `KEYSYNC_OPERATOR_ID` is back as a required env var. Operators see
  it next to the generated key in the dashboard. The JWT model
  derived it from the claim; without the JWT, customers paste it
  alongside the key.
- `KEYSYNC_AUTH_URL` is gone — no optimum-auth involvement.
- `billing_client._do` drops the ScopeChangedError branch. A 401
  from billing is now terminal (key was revoked / unknown / scoped
  to another operator).
- `cli._compute_plan` / `_apply_plan` lose their re-exchange closures.
  The bearer is static, threaded directly.
- pyjwt dropped from runtime deps; httpx + tenacity stay (still
  used by beacon_client + billing_client).
- README rewritten to describe the direct flow.

Companion changes: optimum-auth#16 and optimum-billing#63 are now
unused; both will be closed.

Tests: 43/43 green (was 44; one scope_changed-specific test removed,
others trivialised to the new shape). ruff + mypy --strict clean.

_Written with Claude Code_
KEYSYNC_OPERATOR_ID is now optional. When omitted, the CLI calls
GET /api/v1/me on first contact with billing and uses the
operator_id from the response. Explicit env or flag wins, so
existing configs keep working unchanged.

The server already knew the operator_id from the ovi_* key via
combinedAuth; the /me endpoint just echoes it back. One fewer
correlated value for customers to paste, no second host to
configure (auth.getoptimum.io is gateway-key-only and doesn't
authenticate ovi_*).

Also fixes doc rot: operator_id was described as a UUID in
several places, but it's TEXT holding a stringified
auto-increment integer (migration 000011_integer_operator_id).
The example placeholder is now "42" instead of a UUID.

Depends on getoptimum/optimum-billing#71 (GET /api/v1/me)
being deployed.
@CryptoFewka

Copy link
Copy Markdown
Contributor Author

Closing alongside getoptimum/optimum-billing#71. Decision is to land /me in optimum-auth instead — that scopes the change as the first step of migrating operator-credential validation out of billing, rather than a one-off endpoint.

The keysync-side wiring will be similar to what's here (KEYSYNC_OPERATOR_ID optional, lazy resolution on first call), but the lookup will hit auth.getoptimum.io/api/v1/me rather than billing.getoptimum.io/api/v1/me — likely via a new KEYSYNC_AUTH_URL env var. The doc fixes ("operator UUID" → operator_id, stale JWT-exchange wording in pyproject) and MeResponse/get_me plumbing should mostly carry over.

Branch left up (feat/lazy-operator-id).

Written with Claude Code

@CryptoFewka CryptoFewka closed this Jun 8, 2026
@CryptoFewka
CryptoFewka deleted the feat/lazy-operator-id branch June 25, 2026 14:50
CryptoFewka added a commit that referenced this pull request Jun 25, 2026
#1 desired_state._read_indices: reject a bool/float/negative/string
   validator_index instead of letting int() silently coerce it to a wrong
   index (the JSON path is the no-beacon input we steer customers to).
#2 api_client.batch_register_keys: wrap int(added/skipped) so a null or
   non-numeric count surfaces as ApiError, not a raw exception escaping the
   public boundary.
#3/#4 chain_id cross-check: BeaconClient.get_chain_id() reads
   /eth/v1/config/deposit_contract; the CLI verifies it matches the configured
   chain_id before computing a plan. Catches a testnet beacon paired with the
   mainnet default, and a wrong beacon_url (which would otherwise resolve an
   empty set and look like a mass exit). Keeps the convenience defaults.
#5 partial-progress: batch_register_keys takes an on_progress callback invoked
   per committed chunk, so a mid-register failure logs what actually landed
   instead of 0.

Adds tests for each. 107 passed, ruff and mypy clean.
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.

1 participant