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
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ Start with the [README](../README.md) for install and a quick start.

- [Install](guides/install.md): platforms, download verification, building from source, credential stores, completions
- [Log in and use profiles](guides/login-and-profiles.md): browser login, API keys, scope, profiles, `voltage context`
- [Wallets](guides/wallets.md): create, inspect, ledger, policies, delete
- [Payments](guides/payments.md): receive, send, wait, invoices, quotes, prices
- [Requests](guides/requests.md): request bodies, friendly flags, filters, paging
- [Webhooks](guides/webhooks.md): create with a saved secret, events, deliveries, key rotation
- [Checkout](guides/checkout.md): session and stream tokens, events
- [Scripting](guides/scripting.md): JSON, `VOLTAGE_EXECUTE`, running without a person

Expand Down
6 changes: 4 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ python3 scripts/check-coverage.py
Integration tests start mock HTTP servers on port 0 and never contact a real service. They
cover every operation in the contract, query encoding, complete JSON bodies, credential
selection, describe-by-default changes, secret output, pagination, payment waits, uncertain submissions,
checkout tokens and streams, and the credential lifecycle. Live payment tests must use an
explicitly configured test-network wallet; routine tests never send a live payment.
checkout tokens and streams, and the credential lifecycle. Every change the docs show with
`--execute` also runs as an offline `--dry-run`, with stub bodies for `--data` files, so
documented flags, units, scope, and request bodies keep validating. Live payment tests must
use an explicitly configured test-network wallet; routine tests never send a live payment.

The manual CRAP risk check is described in [the style guide](quality.md#crap-risk-check).

Expand Down
46 changes: 46 additions & 0 deletions docs/guides/wallets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Wallets

```sh
voltage wallets list --profile prod
```

A wallet belongs to an organization and one environment. `--profile` (or `--org` and `--env`) selects them; see [log in and use profiles](login-and-profiles.md). Payment commands take the wallet with `--wallet WALLET_ID`, or `VOLTAGE_WALLET_ID` when no `--profile` is selected, and on a terminal they offer a picker when it is missing.

## Create a wallet

A wallet is backed by a line of credit. Select one environment, then pass the name, the line of credit, the network, and the credit limit in the line of credit's integer base units:

```sh
voltage credit-lines list --profile prod
voltage wallets create --profile prod --name treasury \
--credit-line CREDIT_LINE_ID --network mutinynet --limit 100000 --execute
```

`--metadata KEY=VALUE` (repeatable) attaches your own labels, and `--id` sets the new wallet's UUID yourself. Without `--execute`, the command only describes the wallet it would create.

## Inspect a wallet

```sh
voltage wallets get WALLET_ID --profile prod
voltage wallets ledger list WALLET_ID --profile prod --all
voltage wallets payments summary WALLET_ID --profile prod
```

The ledger lists every credit, hold, capture, and release on the wallet. `--all` follows every page; with `--output ndjson` each page is written as it arrives.

## Change a wallet

```sh
voltage wallets update WALLET_ID --profile prod --name operations --execute
voltage wallets policies get WALLET_ID --profile prod
voltage wallets policies update WALLET_ID --profile prod --data @policies.json --execute
```

Policies have no friendly flags: read the current policies, edit the JSON, and send the complete object with `--data`. Deleting a wallet cannot be undone, so check the request with a dry run first:

```sh
voltage wallets delete WALLET_ID --profile prod --dry-run
voltage wallets delete WALLET_ID --profile prod --execute
```

Related: [payments](payments.md), [requests](requests.md), [Voltage API wallets](https://voltageapi.com/v1/docs).
79 changes: 79 additions & 0 deletions docs/guides/webhooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Webhooks

```sh
voltage webhooks create --profile prod --name orders \
--url https://example.com/voltage --event receive.completed \
--output-file webhook.json --execute
```

A webhook sends events from one environment to your URL. Creating one returns its shared secret once, so the CLI refuses to run without `--output-file PATH` or `--show-secrets`. `--output-file` saves the complete response, including the secret, to a new owner-only file; `--show-secrets` prints it, so avoid it in shared terminals and logs. See [secrets and redaction](../concepts/safety-model.md#secrets-and-redaction).

To see the environment's webhooks and one webhook's settings:

```sh
voltage webhooks list --profile prod
voltage webhooks get WEBHOOK_ID --profile prod
```

## Choose events

`--event` is repeatable and names an event as `KIND.TYPE`:

| Kind | Types |
|---|---|
| `receive` | `generated`, `detected`, `refreshed`, `expired`, `succeeded`, `completed`, `failed` |
| `send` | `succeeded`, `failed` |
| `test` | `created` |

`webhooks update` replaces the whole selection, so pass every event you still want:

```sh
voltage webhooks update WEBHOOK_ID --profile prod \
--event receive.completed --event send.failed --execute
```

## Test, pause, and resume

A test delivery takes a body with a new `delivery_id` and a sample event `payload`. The smallest is a `test.created` event, for example in `test-delivery.json`:

```json
{
"delivery_id": "1f0b5c2e-3a4d-4e8f-9b6a-7c2d1e0f4a5b",
"payload": {
"type": "test",
"detail": { "event": "created", "data": "Checking the endpoint" }
}
}
```

A `receive` or `send` payload carries a whole payment instead; the API reference has an example of each. Stopping pauses deliveries until you start the webhook again:

```sh
voltage webhooks test WEBHOOK_ID --profile prod --data @test-delivery.json --execute
voltage webhooks stop WEBHOOK_ID --profile prod --execute
voltage webhooks start WEBHOOK_ID --profile prod --execute
```

## Rotate the secret

Rotation also returns a new secret once, so it needs `--output-file` too:

```sh
voltage webhooks keys rotate WEBHOOK_ID --profile prod \
--output-file webhook-key.json --execute
```

## Deliveries

Deliveries belong to a webhook, selected with `--webhook`:

```sh
voltage webhooks deliveries summary --profile prod --webhook WEBHOOK_ID
voltage webhooks deliveries list --profile prod --webhook WEBHOOK_ID --all
voltage webhooks deliveries retry DELIVERY_ID --profile prod --webhook WEBHOOK_ID --execute
voltage webhooks deliveries abandon DELIVERY_ID --profile prod --webhook WEBHOOK_ID --execute
```

To verify a delivery's signature in your receiver, see the [Voltage API webhook docs](https://voltageapi.com/v1/docs).

Related: [requests](requests.md), [scripting](scripting.md), [safety model](../concepts/safety-model.md).
68 changes: 8 additions & 60 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,14 @@ mod tests {
const ENV: &str = "22222222-2222-4222-8222-222222222222";
const WALLET: &str = "33333333-3333-4333-8333-333333333333";

mod examples {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/support/examples.rs"
));
}
use examples::{doc_examples, user_docs};

fn api(args: &[&str]) -> ApiInvocation {
let mut full = vec!["voltage"];
full.extend_from_slice(args);
Expand Down Expand Up @@ -1377,66 +1385,6 @@ mod tests {
assert!(Invocation::try_parse_from(["voltage", "bogus"]).is_err());
}

/// The README and the docs index, guides, concepts, reference, and development guide, as
/// (path from the repository root, contents).
fn user_docs() -> Vec<(PathBuf, String)> {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut paths = ["README.md", "docs/README.md", "docs/development.md"]
.map(|path| root.join(path))
.to_vec();
for dir in ["guides", "concepts", "reference"] {
for entry in std::fs::read_dir(root.join("docs").join(dir)).unwrap() {
let path = entry.unwrap().path();
// Only Markdown is documentation; an image or other asset is not read as text.
if path.extension().is_some_and(|extension| extension == "md") {
paths.push(path);
}
}
}
paths
.into_iter()
.map(|path| {
let text = std::fs::read_to_string(&path).unwrap();
(path.strip_prefix(&root).unwrap().to_owned(), text)
})
.collect()
}

/// Every `voltage` line in a documentation shell block, with continuations joined,
/// comments dropped, and pipes, redirections, and leading variable assignments cut away.
fn doc_examples() -> Vec<String> {
let mut examples = Vec::new();
for (_, text) in user_docs() {
let mut shell = false;
let mut line = String::new();
for raw in text.lines().map(str::trim) {
if let Some(language) = raw.strip_prefix("```") {
shell = matches!(language, "sh" | "bash" | "shell" | "console");
continue;
}
if !shell {
continue;
}
line.push_str(raw.trim_end_matches('\\'));
if raw.ends_with('\\') {
continue;
}
let command = line.split(" #").next().unwrap_or_default();
let command = command.split('|').find(|part| part.contains("voltage "));
if let Some(command) = command.and_then(|part| part.split(['<', '>']).next()) {
let start = command.find("voltage ").unwrap_or_default();
let command = command[start..].trim();
// `--build` belongs to the development guide's staging wrapper.
if command != "voltage --build" {
examples.push(command.to_owned());
}
}
line.clear();
}
}
examples
}

/// GitHub's anchors for a document's headings: lowercase, punctuation dropped, spaces
/// as hyphens, and `-1`, `-2`, ... after repeats. Lines inside code fences are skipped.
fn anchors(text: &str) -> Vec<String> {
Expand Down
111 changes: 111 additions & 0 deletions tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use assert_cmd::{Command, assert::Assert};
use serde_json::{Value, json};
use std::{
collections::BTreeSet,
ffi::OsString,
path::Path,
sync::{
Arc,
Expand Down Expand Up @@ -4193,6 +4195,115 @@ async fn device_approval_on_a_terminal_shows_how_long_it_has_waited() {
server.verify().await;
}

mod examples {
include!("support/examples.rs");
}

/// A signet BOLT11 invoice, from the API's webhook test example.
const EXAMPLE_INVOICE: &str = "lntbs10n1p4ft36app509h8j7cecenwaktcsrshu6cx4xsczarwfj4nl4tfjq6y7rz79vmqdqqcqzzsxqrrsssp5grs2zd2al99uw93vja4mc5wus9lshcjteuqwef2xkhlp9ppmkens9qxpqysgqmn4s544p8p0263cv83s3c3qjv69z3xxy3fh64ny2y766mfnqfxrrhjj8s7h0lrkxux23dfyzxfydvgjhrsw2p8z0nw54en5a9rvk4kgqxkh73g";

/// A documented placeholder as a value the CLI accepts: IDs become a fixed UUID, an invoice
/// a signet invoice, and an address a BIP 173 testnet address. Dry runs never send them.
fn placeholder(word: &str) -> &str {
match word {
"BOLT11_INVOICE" => EXAMPLE_INVOICE,
"BITCOIN_ADDRESS" => "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7",
id if id.ends_with("_ID") && id.chars().all(|c| c.is_ascii_uppercase() || c == '_') => {
RESOURCE
}
word => word,
}
}

/// Request bodies for the files the documented `--data` examples name, just complete enough
/// for local validation. A new `@FILE` in the docs needs a body here.
fn data_stubs() -> [(&'static str, Value); 3] {
[
(
"payment.json",
json!({"id": RESOURCE, "wallet_id": WALLET, "currency": "btc", "type": "bolt11",
"data": {"payment_request": EXAMPLE_INVOICE}}),
),
("policies.json", json!({})),
(
"test-delivery.json",
json!({"delivery_id": RESOURCE, "payload": {"type": "test",
"detail": {"event": "created", "data": "Checking the endpoint"}}}),
),
]
}

/// The names in a directory, to show that a run created nothing in it.
fn file_names(dir: &Path) -> BTreeSet<OsString> {
std::fs::read_dir(dir)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect()
}

/// Every change the docs show with `--execute` also passes as a dry run, offline: flags,
/// units, scope, and `--data` bodies still validate as documented, nothing reaches the API,
/// and no file is created.
#[tokio::test(flavor = "multi_thread")]
async fn documented_changes_pass_as_offline_dry_runs() {
let (server, dir) = fixture().await;
let mut settings = seeded_api_key(dir.path(), "key", ACCOUNT_KEY);
for name in ["prod", "staging"] {
settings.config.profiles.insert(
name.into(),
Profile {
organization_id: uuid(ORG),
environment_id: uuid(ENV),
account: "key".into(),
},
);
}
settings.save().unwrap();
let stubs = data_stubs();
for (name, body) in &stubs {
std::fs::write(dir.path().join(name), body.to_string()).unwrap();
}
// `--data -` reads stdin, which the docs fill with `cat payment.json |`.
let stdin = stubs[0].1.to_string();
let before = file_names(dir.path());
let changes: Vec<String> = examples::doc_examples()
.into_iter()
.filter(|example| example.split_whitespace().any(|word| word == "--execute"))
.collect();
assert!(changes.len() >= 19, "{changes:?}");
for example in &changes {
let args = example
.split_whitespace()
.skip(1)
.filter(|word| *word != "--execute")
.map(placeholder);
// Examples without a profile leave the scope to a picker; the variables stand in.
let mut command = cli(dir.path(), &server);
command
.current_dir(dir.path())
.env("VOLTAGE_ORGANIZATION_ID", ORG)
.env("VOLTAGE_ENVIRONMENT_ID", ENV)
.env("VOLTAGE_WALLET_ID", WALLET)
.args(args)
.arg("--dry-run");
if example.contains("--data -") {
command.write_stdin(stdin.clone());
}
let result = command.assert();
let output = result.get_output();
assert!(
output.status.success(),
"{example}: {}",
String::from_utf8_lossy(&output.stderr)
);
let body: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(body["outcome"], "dry_run", "{example}");
}
// No `--output-file`, journal, or other file appears.
assert_eq!(file_names(dir.path()), before);
assert!(server.received_requests().await.unwrap().is_empty());
}

// ---------------------------------------------------------------------------------------
// Prices and conversions
// ---------------------------------------------------------------------------------------
Expand Down
Loading
Loading