Skip to content
Open
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
9 changes: 8 additions & 1 deletion docs/guides/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,20 @@ For a BOLT11 receive, `--qr` prints the invoice as a terminal QR code and `--cop

## Quotes and lines of credit

Quotes apply to USD lines of credit: a USD payment requires `--quote` with a quote for that line of credit.
A wallet on a USD line of credit needs a quote for every payment; without one, the API rejects the payment with `quote_required`. `voltage wallets get WALLET_ID` shows the wallet's `line_of_credit_id`, network, and balance currency.

To receive into a USD wallet, quote the USD amount to BTC, wait until `quotes get` shows a `quote` with no `error`, then receive the same USD amount with that quote:

```sh
voltage quotes create --profile prod --credit-line CREDIT_LINE_ID \
--network mutinynet --amount 10 --unit usd --to btc --execute
voltage quotes get QUOTE_ID --profile prod
voltage payments receive --profile prod --wallet WALLET_ID \
--currency usd --kind bolt11 --amount 10 --unit usd --quote QUOTE_ID --wait ready --execute
```

A quote is single-use and expires; a quoted receive's expiration is capped by the quote's remaining life.

On-chain and BIP21 payments and treasury movements are features Voltage enables per organization; the API rejects them with `feature_flag_disabled` until then, and the CLI says so.

## Prices
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/exit-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@
| 130 | Interrupted |

After exit code 4 for a payment, check its ID before you retry; see [payment IDs](../concepts/safety-model.md#payment-ids-and-the-request-journal). After exit code 5, keep watching with `voltage payments get PAYMENT_ID --profile prod --wait completed`.

Errors for exit codes 2 and 3 carry a `hint:` (or `error.hint` with `--json`) that names the next command: the discovery command for a missing `--org`, `--env`, or `--wallet`, the saved credential and profile names for an unknown or ambiguous selection, `voltage login --account NAME` for an expired login, `voltage auth status` for a rejected credential (HTTP 401), and `voltage context` for a credential that cannot reach the selected scope (HTTP 403). A payment rejected with `quote_required` points to `voltage quotes create`.
2 changes: 1 addition & 1 deletion docs/reference/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ API fields stay inside `data`. With `--all`, JSON output collects the pages into

Diagnostics, prompts, and progress go to stderr. Interactive stderr shows immediate, cancellable status for network requests and waits; redirected stderr gets no spinner/control codes. `-q, --quiet` suppresses optional status and notices, never requested results, errors, warnings, or recovery IDs.

Human errors start with `error:` and may include a safe `hint:`. `--json` makes command-line parse errors machine-readable as well as runtime errors. A downstream consumer that closes stdout early is treated as successful pipeline completion.
Human errors start with `error:`, which leads with the API's reason when it rejects a request, and may include a safe `hint:` and an indented `details:` block of `field value` lines. `--json` reports the complete API response in `error.detail`, and makes command-line parse errors machine-readable as well as runtime errors. A downstream consumer that closes stdout early is treated as successful pipeline completion.

Related: [scripting](../guides/scripting.md), [safety model](../concepts/safety-model.md#secrets-and-redaction).
94 changes: 77 additions & 17 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,19 @@ impl Api {
})?
};
if !(200..300).contains(&status) {
return Err(Error::new(
let mut error = Error::new(
ErrorKind::for_http_status(status),
rejection_message(status, &body),
)
.with_detail(ErrorDetail::Http {
http_status: status,
data: body,
})
.redacted(&self.secrets()));
);
if let Some(hint) = rejection_hint(status, &body) {
error = error.with_hint(hint);
}
return Err(error
.with_detail(ErrorDetail::Http {
http_status: status,
data: body,
})
.redacted(&self.secrets()));
}
Ok(Response {
status,
Expand Down Expand Up @@ -311,6 +315,7 @@ pub(crate) enum BodyFailure {
#[serde(rename_all = "snake_case")]
enum ApiErrorType {
FeatureFlagDisabled,
QuoteRequired,
#[default]
#[serde(other)]
Other,
Expand All @@ -330,19 +335,41 @@ struct ApiErrorView {
error: ApiErrorBody,
}

/// The message for a rejected request; a disabled organization feature names the fix.
/// The message for a rejected request: the API's own reason when it gives one, and for a
/// disabled organization feature, the fix.
fn rejection_message(status: u16, body: &Value) -> String {
let rejection = ApiErrorView::deserialize(body).unwrap_or_default();
match rejection.error.kind {
ApiErrorType::FeatureFlagDisabled => format!(
let reason = rejection.error.detail.as_deref();
match (rejection.error.kind, reason) {
(ApiErrorType::FeatureFlagDisabled, reason) => format!(
"Voltage API returned HTTP {status}: {}. Ask Voltage to enable this feature for the organization.",
rejection
.error
.detail
.as_deref()
.unwrap_or("this feature is not enabled for the organization")
reason.unwrap_or("this feature is not enabled for the organization")
),
ApiErrorType::Other => format!("Voltage API returned HTTP {status}"),
(_, Some(reason)) => format!("Voltage API returned HTTP {status}: {reason}"),
(_, None) => format!("Voltage API returned HTTP {status}"),
}
}

/// The next step for a rejected request. 401 means the API did not accept the credential,
/// and 403 means it was accepted but cannot reach the selected organization or environment.
fn rejection_hint(status: u16, body: &Value) -> Option<&'static str> {
match status {
401 => Some(
"Check the credential with `voltage auth status`, or sign in again with `voltage login`.",
),
403 => Some(
"Check the organization, environment, and credential in use with `voltage context`.",
),
_ => match ApiErrorView::deserialize(body)
.unwrap_or_default()
.error
.kind
{
ApiErrorType::QuoteRequired => Some(
"This wallet is on a USD line of credit, so each payment needs a quote. Create one with `voltage quotes create`, wait until `voltage quotes get QUOTE_ID` shows it, then pass --quote QUOTE_ID. A wallet on a BTC line of credit needs no quote.",
),
ApiErrorType::FeatureFlagDisabled | ApiErrorType::Other => None,
},
}
}

Expand Down Expand Up @@ -1329,6 +1356,22 @@ fn next_page(body: &Value, request: &Request) -> Result<Option<(String, String)>
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn credential_rejections_carry_a_next_step() {
assert!(
rejection_hint(401, &Value::Null)
.unwrap()
.contains("voltage auth status")
);
assert!(
rejection_hint(403, &Value::Null)
.unwrap()
.contains("voltage context")
);
assert_eq!(rejection_hint(404, &Value::Null), None);
assert_eq!(rejection_hint(500, &Value::Null), None);
}
use crate::{
cli::{Command, Invocation},
config::Config,
Expand Down Expand Up @@ -1408,7 +1451,7 @@ mod tests {
}

#[test]
fn disabled_features_are_explained_and_other_rejections_stay_generic() {
fn rejections_lead_with_the_api_reason_and_explain_known_types() {
let disabled = json!({"error": {
"type": "feature_flag_disabled", "code": "disabled",
"detail": "OnChain payments are not enabled for this organization",
Expand All @@ -1421,6 +1464,23 @@ mod tests {
let other = json!({"error": {"type": "invalid_amount", "detail": "too small"}});
assert_eq!(
rejection_message(400, &other),
"Voltage API returned HTTP 400: too small"
);
assert_eq!(rejection_hint(400, &other), None);
let quote = json!({"error": {
"type": "quote_required", "code": "required",
"detail": "Quote is required for payment",
"context": {"field": "quote_id", "required": true}
}});
assert_eq!(
rejection_message(400, &quote),
"Voltage API returned HTTP 400: Quote is required for payment"
);
let hint = rejection_hint(400, &quote).unwrap();
assert!(hint.contains("voltage quotes create"));
assert!(hint.contains("BTC line of credit needs no quote"));
assert_eq!(
rejection_message(400, &json!({"error": {"type": "invalid_amount"}})),
"Voltage API returned HTTP 400"
);
assert_eq!(
Expand Down
64 changes: 50 additions & 14 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,23 @@ impl AuthResponse {
(200..300).contains(&self.status)
}

/// Fail with the server's error code when it is a plain identifier, never its free text.
fn require_success(&self) -> Result<()> {
if self.is_success() {
return Ok(());
}
let error = serde_json::from_slice::<OAuthErrorBody>(&self.body)
/// The server's OAuth error code when it is a plain identifier, never its free text.
fn error_code(&self) -> Option<String> {
serde_json::from_slice::<OAuthErrorBody>(&self.body)
.ok()
.and_then(|body| body.error)
.filter(|code| {
code.len() < 80 && code.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
})
}

/// Fail with the server's error code when it is a plain identifier, never its free text.
fn require_success(&self) -> Result<()> {
if self.is_success() {
return Ok(());
}
let error = self
.error_code()
.unwrap_or_else(|| "authentication_failed".into());
Err(Error::auth(format!(
"Authentication failed (HTTP {}, {error})",
Expand Down Expand Up @@ -610,11 +616,12 @@ pub async fn resolve(
flags: &GlobalFlags,
submission: &SubmissionState,
) -> Result<Credential> {
let (name, account) =
match select(settings, scope, flags).map_err(|rejected| rejected.problem.error(scope))? {
Selected::Ambient(key) => return Ok(Credential::ApiKey(key)),
Selected::Saved { name, account } => (name, account),
};
let (name, account) = match select(settings, scope, flags)
.map_err(|rejected| rejected.problem.error(scope, settings))?
{
Selected::Ambient(key) => return Ok(Credential::ApiKey(key)),
Selected::Saved { name, account } => (name, account),
};
match account.kind {
AccountKind::ApiKey => settings.read_credential(&name),
AccountKind::User => {
Expand All @@ -640,7 +647,7 @@ pub async fn resolve(
submission.session_changing(SessionChange::Refresh {
account: name.clone(),
});
let refreshed = Credential::Login(refresh(&url, login, flags.terminal()).await?);
let refreshed = Credential::Login(refresh(&url, login, &name, flags.terminal()).await?);
settings.write_credential(&name, account.store, &refreshed)?;
submission.session_settled();
Ok(refreshed)
Expand All @@ -657,7 +664,7 @@ fn saved_login(settings: &Settings, name: &str) -> Result<Login> {

/// Rotate an expiring login; the refresh token is single use, so the result is saved
/// before any caller sees it.
async fn refresh(url: &str, login: Login, terminal: Terminal) -> Result<Login> {
async fn refresh(url: &str, login: Login, account: &str, terminal: Terminal) -> Result<Login> {
let response = auth_request(
client(AUTH_TIMEOUT)?
.post(format!("{url}/oauth/token"))
Expand All @@ -669,6 +676,16 @@ async fn refresh(url: &str, login: Login, terminal: Terminal) -> Result<Login> {
terminal,
)
.await?;
// OAuth reports an expired, revoked, or already spent refresh token as `invalid_grant`.
if !response.is_success() && response.error_code().as_deref() == Some("invalid_grant") {
return Err(Error::auth(format!(
"The saved login for {account} has expired or was revoked"
))
// `login` refuses to replace a saved credential, so the stale one goes first.
.with_hint(format!(
"Remove it with `voltage logout --local --account {account}`, then sign in again with `voltage login --account {account}`."
)));
}
response.require_success()?;
let token: TokenResponse = response.json("Invalid token response")?;
token.into_login(login.user_id, login.email)
Expand Down Expand Up @@ -827,7 +844,26 @@ pub async fn discover(
what: Discovery,
submission: &SubmissionState,
) -> Result<Value> {
let requires_login = || Error::auth("Organization/environment discovery requires a user login");
// An API key cannot list organizations or environments; say which key was used.
let requires_login = || {
let command = match what {
Discovery::Organizations => "organizations list",
Discovery::Environments => "environments list",
};
if ambient_api_key(flags).is_some() {
return Error::auth(format!(
"{command} needs a browser login, but this command used VOLTAGE_API_KEY from the environment"
))
.with_hint(
"Pass --account NAME to use a saved browser login (`voltage login` creates one), or unset VOLTAGE_API_KEY.",
);
}
let used = settings.account_name(scope).unwrap_or_default();
Error::auth(format!(
"{command} needs a browser login, but the selected credential {used} is an API key"
))
.with_hint("Select a browser login with --account NAME, or sign in with `voltage login`.")
};
let token = match what {
Discovery::Organizations => match resolve(settings, scope, flags, submission).await? {
Credential::Login(login) => login.access_token,
Expand Down
Loading
Loading