diff --git a/docs/README.md b/docs/README.md index 91cd112..1006874 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/development.md b/docs/development.md index beaba13..76fe374 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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). diff --git a/docs/guides/wallets.md b/docs/guides/wallets.md new file mode 100644 index 0000000..6db1781 --- /dev/null +++ b/docs/guides/wallets.md @@ -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). diff --git a/docs/guides/webhooks.md b/docs/guides/webhooks.md new file mode 100644 index 0000000..1c5ee39 --- /dev/null +++ b/docs/guides/webhooks.md @@ -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). diff --git a/src/cli.rs b/src/cli.rs index e6fbff1..5658b27 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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); @@ -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 { - 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 { diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index fe21dbf..022786c 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -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, @@ -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 { + 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 = 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 // --------------------------------------------------------------------------------------- diff --git a/tests/support/examples.rs b/tests/support/examples.rs new file mode 100644 index 0000000..bb75738 --- /dev/null +++ b/tests/support/examples.rs @@ -0,0 +1,65 @@ +// Documentation examples, shared by the unit test that parses them and the integration +// test that runs every documented change offline with `--dry-run`. Included with +// `include!`, so it names its own imports. + +use std::path::PathBuf; + +/// The README and the docs index, guides, concepts, reference, and development guide, as +/// (path from the repository root, contents). +pub 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. +pub fn doc_examples() -> Vec { + 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 +}