open-standards/openpay/crates/op-cli/src/lib.rs
Transaction Science a437feb8ab Parallel ingest: 9 issues' worth of work across openpay / smart-byte / eoc
openpay/
  compliance/                     PCI-DSS readiness package (scope-map,
                                  pci-zero-architecture, hsm-kms-guidance,
                                  qsa-checklist), citing PCI-DSS v4.0.1
                                  requirements. (#1)
  examples/pci-zero/              Runnable example demonstrating a deployment
                                  where merchant code never touches a PAN. (#1)
  crates/op-core/                 Tokenized<Card> typestate + NetworkToken
                                  + CardNetwork + Cryptogram. (#2)
  crates/op-rails-card/network_token/
                                  NetworkTokenProvider trait + VTS adapter
                                  + MDES adapter, with deterministic stubs
                                  behind a 'live' feature flag. (#2)
  crates/op-driver-sdk/conformance/network_token.rs
                                  Conformance harness for third-party
                                  NetworkTokenProvider implementations. (#2)
  crates/op-cli/src/ledger/       'openpay ledger as-of' subcommand for
                                  bi-temporal queries. (#3)
  crates/op-orchestrator/src/telemetry.rs
                                  OpenTelemetry span emission per
                                  Payment<S> transition with semantic-
                                  conventions tags. (#3)
  docs/observability/             Grafana dashboard JSON + wiring guide
                                  for the bi-temporal ledger lens. (#3)

smart-byte/
  smart-byte-rs/                  Rust reference implementation: envelope
                                  encode/decode (canonical CBOR + BLAKE3
                                  content addressing), Ed25519 sign +
                                  verify, lockstep BFT commit MVP, Iroh
                                  transport scaffold, CLI. (#4)
  spec/identity_and_key_rotation.md
                                  Adopts KERI SAIDs + key-event log
                                  format; explicitly does not adopt ACDC
                                  chaining wholesale. (#5)
  conformance/v1/                 21 test vectors covering canonicalisation,
                                  SAID computation, signature verification,
                                  lockstep commit, gossip propagation.
                                  CI gate enforces spec/vector coupling. (#6)

eoc/
  eoc-rs/                         Rust + WASM reference implementation:
                                  4-stage cascade (cache -> kv -> graph ->
                                  neural), joule counters via RAPL / NVML /
                                  powermetrics, CLI, wasm-bindgen surface.
                                  18 tests pass. (#7)
  eoc-bench/                      Benchmark harness, MT-Bench and Router
                                  case sets, 'joules per MT-Bench point'
                                  metric, 'eoc-bench run | compare' CLI. (#8)
  standards/sci-for-ai-submission.md
                                  Submission proposal mapping EOC's
                                  receipt schema to GSF SCI-for-AI. (#9)
  cookbook/                       HuggingFace-style cookbook (markdown +
                                  runnable Python) demonstrating EOC's
                                  cascade with HF AI Energy Score. (#9)
  docs/bench-v0.md                Reproducibility doc for eoc-bench.

Workspace state:
- openpay clippy -- -D warnings: clean across all touched crates
- smart-byte-rs cargo test --workspace + clippy: clean
- eoc-rs cargo test --workspace + clippy: clean (18 tests)
- eoc-bench cargo check --workspace + clippy + 15 tests: clean

Rust 1.95.0 edition 2024 pinned at the open-standards root via
rust-toolchain.toml; every workspace inherits.

Closes #1 #2 #3 #4 #5 #6 #7 #8 #9.
2026-05-23 13:24:05 -04:00

701 lines
22 KiB
Rust

//! # `op-cli` — operator CLI for `OpenPay`
//!
//! A thin command-line front-end to a running [`op_server`]
//! deployment. Operators reach a deployment over plain HTTP and
//! issue read / write requests against the same JSON surface
//! merchants use; this crate is the missing terminal ergonomics
//! layer.
//!
//! The library half exposes:
//!
//! - [`Cli`]: the top-level clap parser (derive style). Embedders
//! that want to extend the command tree can re-use it.
//! - [`Client`]: a [`reqwest::blocking::Client`] wrapper that
//! handles base URL + optional `OP_API_KEY` bearer auth and
//! pretty-prints non-2xx responses.
//! - [`run`]: the entry point the binary calls; takes a parsed
//! [`Cli`] and either succeeds (printing pretty JSON to stdout)
//! or returns an [`Error`].
//!
//! No async runtime is pulled in — the blocking `reqwest` client
//! keeps the CLI snappy and shrinks dependency surface.
//!
//! ## Environment
//!
//! - `OP_SERVER_URL` — default `http://127.0.0.1:8080`. Overridden
//! by `--server`.
//! - `OP_API_KEY` — optional bearer token. Overridden by
//! `--api-key`.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![warn(clippy::pedantic)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::missing_panics_doc)]
use std::time::Duration;
use clap::{Args, Parser, Subcommand};
use serde_json::{Value, json};
pub mod ledger;
/// Errors the CLI can emit. Surface in `Display` form to the user;
/// `main` maps these to exit code 1.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Network-layer failure reaching the configured server.
#[error("couldn't reach {url}: {source}")]
Network {
/// The endpoint the CLI tried to hit.
url: String,
/// Underlying reqwest error.
source: reqwest::Error,
},
/// Server responded but with a non-2xx status. Body is included
/// verbatim so operators can see the server's error envelope.
#[error("server returned {status}: {body}")]
HttpStatus {
/// HTTP status code.
status: u16,
/// Raw response body.
body: String,
},
/// Response wasn't valid JSON (or otherwise couldn't be decoded).
#[error("invalid response payload: {0}")]
BadPayload(String),
}
/// Top-level CLI parser. Holds the shared connection options and a
/// single subcommand.
#[derive(Debug, Parser)]
#[command(
name = "op",
version,
about = "OpenPay operator CLI",
long_about = "Inspect and manage a running op-server deployment from the terminal.\n\nReads OP_SERVER_URL (default http://127.0.0.1:8080) and OP_API_KEY\nfrom the environment unless overridden by --server / --api-key."
)]
pub struct Cli {
/// Base URL of the op-server deployment (no trailing slash).
/// Falls back to `OP_SERVER_URL`, then `http://127.0.0.1:8080`.
#[arg(
long,
env = "OP_SERVER_URL",
default_value = "http://127.0.0.1:8080",
global = true
)]
pub server: String,
/// Optional bearer token sent as `Authorization: Bearer …`.
/// Falls back to `OP_API_KEY`.
#[arg(long, env = "OP_API_KEY", global = true)]
pub api_key: Option<String>,
/// The action to perform.
#[command(subcommand)]
pub command: Command,
}
/// Top-level subcommand surface.
#[derive(Debug, Subcommand)]
pub enum Command {
/// `GET /health`. Liveness check.
Health,
/// `GET /readiness`. Store-counts readiness report.
Readiness,
/// Refund inspection and creation.
#[command(subcommand)]
Refund(RefundCommand),
/// Dispute inspection.
#[command(subcommand)]
Dispute(DisputeCommand),
/// Settlement batch inspection.
#[command(subcommand)]
Batch(BatchCommand),
/// Subscription inspection.
#[command(subcommand)]
Subscription(SubscriptionCommand),
/// Foreign-exchange quote / convert.
#[command(subcommand)]
Fx(FxCommand),
/// Webhook endpoint management.
#[command(subcommand)]
Webhooks(WebhooksCommand),
/// Audit window report.
#[command(subcommand)]
Audit(AuditCommand),
/// Bi-temporal time-travel queries against the ledger substrate.
#[command(subcommand)]
Ledger(LedgerCommand),
}
/// `op ledger ...` subcommands.
#[derive(Debug, Subcommand)]
pub enum LedgerCommand {
/// Point query at a `(valid_time, transaction_time)` coordinate.
///
/// See [`crate::ledger::as_of`] for the bi-temporal semantics.
AsOf(ledger::AsOfArgs),
}
/// `op refund ...` subcommands.
#[derive(Debug, Subcommand)]
pub enum RefundCommand {
/// `GET /v1/refunds/{id}`.
Get {
/// Refund UUID.
id: String,
},
/// `POST /v1/refunds`.
Create(RefundCreateArgs),
}
/// Args for `op refund create`. Maps 1:1 onto the
/// `CreateRefundRequest` envelope in `op-server`.
#[derive(Debug, Args)]
pub struct RefundCreateArgs {
/// Original ledger transaction id (UUID).
#[arg(long = "tx-id")]
pub tx_id: String,
/// Amount in minor units (cents for USD).
#[arg(long = "amount")]
pub amount: i64,
/// ISO 4217 currency code.
#[arg(long = "currency")]
pub currency: String,
/// Refund reason code (`customer_request`, `duplicate_charge`, …).
#[arg(long = "reason")]
pub reason: String,
/// Caller-supplied idempotency key.
#[arg(long = "external-id")]
pub external_id: Option<String>,
/// Unix epoch seconds when the request was made (caller clock).
#[arg(long = "requested-at-unix-secs")]
pub requested_at_unix_secs: u64,
}
/// `op dispute ...` subcommands.
#[derive(Debug, Subcommand)]
pub enum DisputeCommand {
/// `GET /v1/disputes/{id}`.
Get {
/// Dispute UUID.
id: String,
},
}
/// `op batch ...` subcommands.
#[derive(Debug, Subcommand)]
pub enum BatchCommand {
/// `GET /v1/settlement/batches/{id}`.
Get {
/// Batch UUID.
id: String,
},
}
/// `op subscription ...` subcommands.
#[derive(Debug, Subcommand)]
pub enum SubscriptionCommand {
/// `GET /v1/subscriptions/{id}`.
Get {
/// Subscription UUID.
id: String,
},
/// `GET /v1/subscriptions?customer_ref=…`.
List {
/// Customer reference to list for.
#[arg(long = "customer-ref")]
customer_ref: String,
},
}
/// `op fx ...` subcommands.
#[derive(Debug, Subcommand)]
pub enum FxCommand {
/// `GET /v1/fx/quote?from=X&to=Y`.
Quote {
/// Source currency.
#[arg(long)]
from: String,
/// Target currency.
#[arg(long)]
to: String,
},
/// `POST /v1/fx/convert`.
Convert {
/// Source currency.
#[arg(long)]
from: String,
/// Target currency.
#[arg(long)]
to: String,
/// Source amount in minor units.
#[arg(long = "amount-minor")]
amount_minor: i64,
},
}
/// `op webhooks ...` subcommands.
#[derive(Debug, Subcommand)]
pub enum WebhooksCommand {
/// `POST /v1/webhooks/endpoints`.
Register {
/// Delivery URL.
#[arg(long)]
url: String,
/// Shared HMAC secret.
#[arg(long)]
secret: String,
/// Event filter (repeatable). `*` matches everything.
#[arg(long = "event", required = true, num_args = 1..)]
event: Vec<String>,
},
}
/// `op audit ...` subcommands.
#[derive(Debug, Subcommand)]
pub enum AuditCommand {
/// `GET /v1/audit/report?start_tx=…&end_tx=…`.
Window {
/// Window start (tx count).
#[arg(long = "start-tx")]
start_tx: u64,
/// Window end (tx count).
#[arg(long = "end-tx")]
end_tx: u64,
/// Wall-clock seconds stamped on the generated report.
/// Defaults to 0 if the caller doesn't care.
#[arg(long = "generated-at-unix-secs", default_value_t = 0)]
generated_at_unix_secs: u64,
},
}
/// Thin reqwest wrapper that the subcommand dispatch reaches into.
///
/// `Client` owns a blocking [`reqwest::blocking::Client`] with a
/// short connect timeout, the configured base URL, and the optional
/// bearer token. `get` / `post` issue a request, surface 2xx
/// responses as parsed JSON, and turn anything else into an
/// [`Error`].
pub struct Client {
base: String,
api_key: Option<String>,
http: reqwest::blocking::Client,
}
impl Client {
/// Build a new client. `base` is the server's URL root (e.g.
/// `http://127.0.0.1:8080`); trailing slashes are trimmed.
#[must_use]
pub fn new(base: impl Into<String>, api_key: Option<String>) -> Self {
let base = base.into().trim_end_matches('/').to_owned();
let http = reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.build()
.expect("reqwest client build");
Self {
base,
api_key,
http,
}
}
/// Resolve a relative path against the configured base.
fn url(&self, path: &str) -> String {
if path.starts_with('/') {
format!("{}{}", self.base, path)
} else {
format!("{}/{}", self.base, path)
}
}
/// Send an HTTP request and decode its 2xx response body as JSON.
fn send(&self, req: reqwest::blocking::RequestBuilder, url: &str) -> Result<Value, Error> {
let req = if let Some(key) = &self.api_key {
req.bearer_auth(key)
} else {
req
};
let res = req.send().map_err(|e| Error::Network {
url: url.to_owned(),
source: e,
})?;
let status = res.status();
let text = res
.text()
.map_err(|e| Error::BadPayload(format!("read body: {e}")))?;
if !status.is_success() {
return Err(Error::HttpStatus {
status: status.as_u16(),
body: text,
});
}
if text.is_empty() {
return Ok(Value::Null);
}
serde_json::from_str(&text).map_err(|e| Error::BadPayload(format!("parse json: {e}")))
}
/// `GET path`. Returns the parsed JSON body on 2xx.
pub fn get(&self, path: &str) -> Result<Value, Error> {
let url = self.url(path);
self.send(self.http.get(&url), &url)
}
/// `POST path` with the given JSON body. Returns the parsed
/// response body on 2xx.
pub fn post(&self, path: &str, body: &Value) -> Result<Value, Error> {
let url = self.url(path);
self.send(self.http.post(&url).json(body), &url)
}
}
/// Execute a parsed CLI invocation. Writes pretty-printed JSON to
/// stdout on success; returns the underlying [`Error`] otherwise.
///
/// Most subcommands speak HTTP to a running [`op_server`] deployment.
/// The `ledger` family is the exception — bi-temporal queries run
/// in-process against the local graph substrate, so they bypass
/// [`Client`] entirely and write their own output.
pub fn run(cli: Cli) -> Result<(), Error> {
if let Command::Ledger(LedgerCommand::AsOf(args)) = cli.command {
return ledger::run_as_of(&args);
}
let client = Client::new(cli.server, cli.api_key);
let value = dispatch(&client, cli.command)?;
print_json(&value);
Ok(())
}
/// Pretty-print a JSON value to stdout. Falls back to a debug
/// rendering if serialization somehow fails (`serde_json` never
/// fails on already-parsed values, but the type signature forces
/// us to handle the result).
pub fn print_json(value: &Value) {
match serde_json::to_string_pretty(value) {
Ok(rendered) => println!("{rendered}"),
Err(_) => println!("{value:?}"),
}
}
/// Dispatch a single subcommand against `client`. Split out from
/// [`run`] so tests can drive it directly without intercepting
/// stdout.
pub fn dispatch(client: &Client, command: Command) -> Result<Value, Error> {
match command {
Command::Health => client.get("/health"),
Command::Readiness => client.get("/readiness"),
Command::Refund(RefundCommand::Get { id }) => {
client.get(&format!("/v1/refunds/{id}"))
}
Command::Refund(RefundCommand::Create(a)) => {
let body = json!({
"original_tx_id": a.tx_id,
"amount_minor": a.amount,
"currency": a.currency,
"reason": a.reason,
"external_id": a.external_id,
"requested_at_unix_secs": a.requested_at_unix_secs,
"metadata": [],
});
client.post("/v1/refunds", &body)
}
Command::Dispute(DisputeCommand::Get { id }) => {
client.get(&format!("/v1/disputes/{id}"))
}
Command::Batch(BatchCommand::Get { id }) => {
client.get(&format!("/v1/settlement/batches/{id}"))
}
Command::Subscription(SubscriptionCommand::Get { id }) => {
client.get(&format!("/v1/subscriptions/{id}"))
}
Command::Subscription(SubscriptionCommand::List { customer_ref }) => {
// reqwest will URL-encode the query value for us.
client.get(&format!("/v1/subscriptions?customer_ref={customer_ref}"))
}
Command::Fx(FxCommand::Quote { from, to }) => {
client.get(&format!("/v1/fx/quote?from={from}&to={to}"))
}
Command::Fx(FxCommand::Convert { from, to, amount_minor }) => {
let body = json!({
"from": from,
"to": to,
"amount_minor": amount_minor,
});
client.post("/v1/fx/convert", &body)
}
Command::Webhooks(WebhooksCommand::Register { url, secret, event }) => {
let body = json!({
"url": url,
"secret": secret,
"event_filters": event,
});
client.post("/v1/webhooks/endpoints", &body)
}
Command::Audit(AuditCommand::Window { start_tx, end_tx, generated_at_unix_secs }) => {
client.get(&format!(
"/v1/audit/report?start_tx={start_tx}&end_tx={end_tx}&generated_at_unix_secs={generated_at_unix_secs}"
))
}
// Local-only — handled by `run` before dispatch. We never
// reach this arm in practice; it exists to keep the match
// exhaustive and clippy happy.
Command::Ledger(_) => Err(Error::BadPayload(
"ledger subcommands are dispatched in-process, not over HTTP".into(),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
/// `clap` itself can validate the command tree at build time.
#[test]
fn clap_command_tree_is_well_formed() {
Cli::command().debug_assert();
}
#[test]
fn parses_health() {
let cli = Cli::try_parse_from(["op", "health"]).expect("parse");
assert!(matches!(cli.command, Command::Health));
assert_eq!(cli.server, "http://127.0.0.1:8080");
assert!(cli.api_key.is_none());
}
#[test]
fn parses_readiness() {
let cli = Cli::try_parse_from(["op", "readiness"]).expect("parse");
assert!(matches!(cli.command, Command::Readiness));
}
#[test]
fn server_flag_overrides_default() {
let cli = Cli::try_parse_from(["op", "--server", "http://1.2.3.4:9000", "health"])
.expect("parse");
assert_eq!(cli.server, "http://1.2.3.4:9000");
}
#[test]
fn api_key_flag_overrides_env() {
let cli = Cli::try_parse_from(["op", "--api-key", "secret123", "health"]).expect("parse");
assert_eq!(cli.api_key.as_deref(), Some("secret123"));
}
#[test]
fn parses_refund_get() {
let cli = Cli::try_parse_from([
"op",
"refund",
"get",
"00000000-0000-0000-0000-000000000001",
])
.expect("parse");
match cli.command {
Command::Refund(RefundCommand::Get { id }) => {
assert_eq!(id, "00000000-0000-0000-0000-000000000001");
}
_ => panic!("wrong subcommand"),
}
}
#[test]
fn parses_refund_create() {
let cli = Cli::try_parse_from([
"op",
"refund",
"create",
"--tx-id",
"00000000-0000-0000-0000-000000000001",
"--amount",
"500",
"--currency",
"USD",
"--reason",
"customer_request",
"--external-id",
"ext-1",
"--requested-at-unix-secs",
"1700000000",
])
.expect("parse");
match cli.command {
Command::Refund(RefundCommand::Create(a)) => {
assert_eq!(a.amount, 500);
assert_eq!(a.currency, "USD");
assert_eq!(a.reason, "customer_request");
assert_eq!(a.external_id.as_deref(), Some("ext-1"));
assert_eq!(a.requested_at_unix_secs, 1_700_000_000);
}
_ => panic!("wrong subcommand"),
}
}
#[test]
fn parses_dispute_get() {
let cli = Cli::try_parse_from([
"op",
"dispute",
"get",
"00000000-0000-0000-0000-000000000002",
])
.expect("parse");
assert!(matches!(
cli.command,
Command::Dispute(DisputeCommand::Get { .. })
));
}
#[test]
fn parses_batch_get() {
let cli =
Cli::try_parse_from(["op", "batch", "get", "00000000-0000-0000-0000-000000000003"])
.expect("parse");
assert!(matches!(
cli.command,
Command::Batch(BatchCommand::Get { .. })
));
}
#[test]
fn parses_subscription_get_and_list() {
let g = Cli::try_parse_from([
"op",
"subscription",
"get",
"00000000-0000-0000-0000-000000000004",
])
.expect("parse get");
assert!(matches!(
g.command,
Command::Subscription(SubscriptionCommand::Get { .. })
));
let l = Cli::try_parse_from(["op", "subscription", "list", "--customer-ref", "cust-42"])
.expect("parse list");
match l.command {
Command::Subscription(SubscriptionCommand::List { customer_ref }) => {
assert_eq!(customer_ref, "cust-42");
}
_ => panic!("wrong subcommand"),
}
}
#[test]
fn parses_fx_quote_and_convert() {
let q = Cli::try_parse_from(["op", "fx", "quote", "--from", "USD", "--to", "EUR"])
.expect("parse quote");
match q.command {
Command::Fx(FxCommand::Quote { from, to }) => {
assert_eq!(from, "USD");
assert_eq!(to, "EUR");
}
_ => panic!("wrong subcommand"),
}
let c = Cli::try_parse_from([
"op",
"fx",
"convert",
"--from",
"USD",
"--to",
"EUR",
"--amount-minor",
"12345",
])
.expect("parse convert");
match c.command {
Command::Fx(FxCommand::Convert {
from,
to,
amount_minor,
}) => {
assert_eq!(from, "USD");
assert_eq!(to, "EUR");
assert_eq!(amount_minor, 12_345);
}
_ => panic!("wrong subcommand"),
}
}
#[test]
fn parses_webhooks_register_multi_event() {
let cli = Cli::try_parse_from([
"op",
"webhooks",
"register",
"--url",
"https://example.invalid/hook",
"--secret",
"topsecret",
"--event",
"refund.created",
"--event",
"dispute.created",
])
.expect("parse");
match cli.command {
Command::Webhooks(WebhooksCommand::Register { url, secret, event }) => {
assert_eq!(url, "https://example.invalid/hook");
assert_eq!(secret, "topsecret");
assert_eq!(event, vec!["refund.created", "dispute.created"]);
}
_ => panic!("wrong subcommand"),
}
}
#[test]
fn parses_audit_window() {
let cli = Cli::try_parse_from([
"op",
"audit",
"window",
"--start-tx",
"10",
"--end-tx",
"20",
])
.expect("parse");
match cli.command {
Command::Audit(AuditCommand::Window {
start_tx,
end_tx,
generated_at_unix_secs,
}) => {
assert_eq!(start_tx, 10);
assert_eq!(end_tx, 20);
assert_eq!(generated_at_unix_secs, 0);
}
_ => panic!("wrong subcommand"),
}
}
#[test]
fn client_trims_trailing_slash() {
let c = Client::new("http://127.0.0.1:8080/", None);
assert_eq!(c.url("/health"), "http://127.0.0.1:8080/health");
}
#[test]
fn missing_subcommand_fails() {
let err = Cli::try_parse_from(["op"]).expect_err("must require subcommand");
// clap returns a `DisplayHelp` / `MissingSubcommand` error
// when no subcommand is supplied — either way it is not `Ok`.
let kind = err.kind();
assert!(
matches!(
kind,
clap::error::ErrorKind::MissingSubcommand
| clap::error::ErrorKind::DisplayHelp
| clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
),
"unexpected error kind: {kind:?}"
);
}
}