diff --git a/.claude/skills/nexus-tool-builder/reference/hosting-options.md b/.claude/skills/nexus-tool-builder/reference/hosting-options.md index 0b92aa3..247e6a0 100644 --- a/.claude/skills/nexus-tool-builder/reference/hosting-options.md +++ b/.claude/skills/nexus-tool-builder/reference/hosting-options.md @@ -23,7 +23,7 @@ The `templates/deploy/` folder emits: ## Alternatives (per-tool migration targets) | Provider | Fit | Trade-off | -|---|---|---| +| --- | --- | --- | | **Akash Network** | Best general DePIN fit. Mature compute marketplace, supports arbitrary Docker HTTPS services. | More ops friction than Cloud Run; debugging tooling weaker. | | **Spheron** | Web3 cloud aggregator on top of AWS / Akash. Closest to managed-deploy UX with a DePIN backend. | Newer; smaller ecosystem than Cloud Run. | | **Atoma Network** | Sui-native AI inference DePIN. Best fit when the tool itself is LLM inference. | Not general-purpose hosting — inference workloads only. | diff --git a/.claude/skills/nexus-tool-builder/reference/interface.md b/.claude/skills/nexus-tool-builder/reference/interface.md index 125dde2..610a2b4 100644 --- a/.claude/skills/nexus-tool-builder/reference/interface.md +++ b/.claude/skills/nexus-tool-builder/reference/interface.md @@ -5,14 +5,14 @@ Source: `nexus-sdk/toolkit-rust/src/nexus_tool.rs`. ## Required associated types | Type | Constraints | Purpose | -|---|---|---| +| --- | --- | --- | | `Input` | `JsonSchema + DeserializeOwned + Send` | Generates the input schema. Deserialized from the request body. | | `Output` | `JsonSchema + Serialize + Send`, **must be a Rust `enum`** so the schema gets a top-level `oneOf` | Generates the output schema. Serialized into the response. | ## Required methods | Method | Signature | Notes | -|---|---|---| +| --- | --- | --- | | `fqn` | `fn fqn() -> ToolFqn` | Use the `fqn!("xyz.taluslabs...@1")` macro. | | `new` | `async fn new() -> Self` | Called per request. Inject dependencies here. | | `invoke` | `async fn invoke(&self, input: Self::Input) -> Self::Output` | Main logic. **No `Result`** — errors go in `Output::Err`. | @@ -21,7 +21,7 @@ Source: `nexus-sdk/toolkit-rust/src/nexus_tool.rs`. ## Optional methods (have defaults) | Method | Default | When to override | -|---|---|---| +| --- | --- | --- | | `path` | `""` (root) | When multiple tools live in one crate — each needs a unique path. | | `description` | `""` | Always — surfaces in `/meta`. | | `timeout` | `Duration::from_secs(10)` | Override for slow upstreams; keep below the Leader's request budget. | @@ -30,7 +30,7 @@ Source: `nexus-sdk/toolkit-rust/src/nexus_tool.rs`. ## Generated endpoints (from `NexusTool` impl) | Endpoint | Body | -|---|---| +| --- | --- | | `GET /health` | Status code returned by `health()`. | | `GET /meta` | JSON: `{ fqn, url, timeout, description, input_schema, output_schema }`. | | `POST /invoke` | Deserializes body as `Input`, calls `invoke`, serializes the result as `Output`. | diff --git a/.claude/skills/nexus-tool-builder/reference/security-checklist.md b/.claude/skills/nexus-tool-builder/reference/security-checklist.md index cdc86b1..88c9acf 100644 --- a/.claude/skills/nexus-tool-builder/reference/security-checklist.md +++ b/.claude/skills/nexus-tool-builder/reference/security-checklist.md @@ -7,7 +7,7 @@ its `AUDIT.md` output. ## Off-chain (Rust) — CRITICAL | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | C1 | `Output` is a Rust `enum` (not a struct) with `#[serde(rename_all = "snake_case")]` | Nexus runtime rejects non-`oneOf` schemas. | | C2 | No `unwrap()` / `expect()` / `panic!` on any path reachable from `invoke()` | Panics surface as opaque 500s; Nexus expects typed `Err` variants. | | C3 | No `danger_accept_invalid_certs` or any TLS bypass | Leader-side TLS verification is part of the Nexus trust model. | @@ -23,7 +23,7 @@ its `AUDIT.md` output. ## Off-chain — HIGH | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | H1 | `Input` has `#[serde(deny_unknown_fields)]` | Stops silent acceptance of typos / injection of unknown control fields. | | H2 | Every `NexusTool::path()` is unique within the crate | `bootstrap!` will mount duplicates at the same route; behavior is undefined. | | H3 | `NexusTool::timeout()` is overridden to a value below the Leader request budget but above 2× expected upstream latency | Default 10s is too short for many real APIs and too long for cheap ones. | @@ -38,7 +38,7 @@ its `AUDIT.md` output. ## Off-chain — MEDIUM | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | M1 | `NexusTool::description()` is non-empty | `/meta` exposes this to DAG authors. | | M2 | Every endpoint has a `mockito`-backed happy-path test | Regressions caught at PR time. | | M3 | Every endpoint has at least one error-variant test | Same. | @@ -49,7 +49,7 @@ its `AUDIT.md` output. ## Off-chain — LOW / INFO | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | L1 | Doc comments on every `Input` / `Output` field — they show up in `input_schema` / `output_schema` | DAG authors need this. | | L2 | `Cargo.toml` `description` is set | Helps `cargo metadata` consumers. | | L3 | README has one section per FQN | Convention; consumed by tooling. | @@ -57,7 +57,7 @@ its `AUDIT.md` output. ## On-chain (Move) — CRITICAL | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | C-M1 | `execute` first parameter is `worksheet: &mut ProofOfUID`, last is `ctx: &mut TxContext`, returns `TaggedOutput` | Runtime enforces this signature. | | C-M2 | `worksheet.stamp_with_data(&witness.id, …)` is called on **every** code path before `execute` returns | Without it the Nexus runtime rejects the proof. Easy to miss in early-return branches. | | C-M3 | Witness struct does NOT have the `copy` ability | A copyable witness lets anyone forge proofs. | @@ -68,7 +68,7 @@ its `AUDIT.md` output. ## On-chain — HIGH | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | H-M1 | Output enum has at least one `err`-prefixed variant | Nexus treats them as error variants. | | H-M2 | TaggedOutput field types use the right `as_*()` (number/string/bool/address/raw) | Wrong typing breaks downstream tools at the schema layer, after the Move call succeeded. | | H-M3 | No unbounded loops over caller-controlled `vector` or dynamic field reads | Gas grief / out-of-budget aborts. | @@ -79,7 +79,7 @@ its `AUDIT.md` output. ## On-chain — MEDIUM | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | M-M1 | No `friend` modules outside this package | `friend` widens access; reviewers need to follow the trust path. | | M-M2 | Every shared object is initialized in `init` and not re-creatable | Drift between the published state and the registered witness id breaks Nexus. | | M-M3 | Test for the witness-stamping flow (positive assertion that the worksheet has the expected stamp after `execute`) | Catches silent misuse of `stamp_with_data`. | @@ -87,14 +87,14 @@ its `AUDIT.md` output. ## On-chain — LOW / INFO | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | L-M1 | Doc comments on `execute` and on every `Output` variant | Schema generation reads them. | | L-M2 | Module path matches the FQN convention `..@` | Convention; helps discovery. | ## Cross-cutting (both kinds) | # | Check | Why it matters | -|---|---|---| +| --- | --- | --- | | X1 | FQN version (`@1`) is bumped when output schema changes | Existing DAGs break otherwise. | | X2 | `description` (Rust) / module-level docstrings (Move) accurately describe behavior | Misleading descriptions break DAG composition by humans and agents. | | X3 | Tool is **idempotent** under retry — same input ⇒ same output, side-effect-free for read tools | The Leader retries on transient failures. | diff --git a/.claude/skills/nexus-tool-builder/reference/style-guide.md b/.claude/skills/nexus-tool-builder/reference/style-guide.md index 0e30d0a..d6a15d1 100644 --- a/.claude/skills/nexus-tool-builder/reference/style-guide.md +++ b/.claude/skills/nexus-tool-builder/reference/style-guide.md @@ -28,7 +28,7 @@ Err { ## Interface design | ✅ Do | ❌ Don't | -|---|---| +| --- | --- | | Build a tool that encapsulates the API's surface (one tool per endpoint, parameterized). | Build a tool that only does one hardcoded call (e.g. "BTC-USD spot price"). | | Split `prompt` and `context` into separate input ports even if the API merges them. | Merge them into one input port — the DAG can't set defaults for fields combined with edge data. | | Accept `json_schema` as input and validate generic responses against it (where applicable). | Hardcode the output schema for a single endpoint when the underlying API serves many. | diff --git a/.github/workflows/deploy-payments-stripe-mainnet.yml b/.github/workflows/deploy-payments-stripe-mainnet.yml deleted file mode 100644 index 3d90565..0000000 --- a/.github/workflows/deploy-payments-stripe-mainnet.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: deploy-payments-stripe-mainnet - -on: - push: - tags: - - "v-payments-stripe-*" - -permissions: - contents: read - id-token: write - -env: - PROJECT_ID: talus-tools-mainnet - REGION: us-central1 - REPO: nexus-tools - CRATE: payments-stripe - SERVICE: payments-stripe-mainnet - -jobs: - guard: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Require testnet success on this commit - uses: actions/github-script@v7 - with: - script: | - const { owner, repo } = context.repo; - const ref = context.sha; - const { data } = await github.rest.checks.listForRef({ - owner, repo, ref, check_name: 'build-deploy-register' - }); - const ok = data.check_runs.some(c => - c.name === 'build-deploy-register' && c.conclusion === 'success' - ); - if (!ok) { - core.setFailed('Testnet deploy must succeed on this commit before mainnet.'); - } - - build-deploy-register: - needs: guard - runs-on: ubuntu-latest - environment: mainnet - steps: - - uses: actions/checkout@v4 - - - id: auth - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER_MAINNET }} - service_account: github-actions@${{ env.PROJECT_ID }}.iam.gserviceaccount.com - - - uses: google-github-actions/setup-gcloud@v2 - - - name: Configure Docker - run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet - - - name: Build and push image - env: - SHA: ${{ github.sha }} - run: | - IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${CRATE}:${SHA}" - docker build -f tools/${CRATE}/deploy/Dockerfile -t "$IMAGE" . - docker push "$IMAGE" - - - name: Deploy to Cloud Run - env: - SHA: ${{ github.sha }} - run: | - export REGISTRY="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}" - envsubst < tools/${CRATE}/deploy/cloud-run.mainnet.yaml > /tmp/svc.yaml - gcloud run services replace /tmp/svc.yaml --region "$REGION" --quiet - URL=$(gcloud run services describe "$SERVICE" --region "$REGION" --format='value(status.url)') - echo "URL=$URL" >> "$GITHUB_ENV" - - - name: Smoke test - run: | - for p in $(jq -r '.[]' tools/${CRATE}/paths.json); do - curl -fsS "${URL}${p}/health" - curl -fsS "${URL}${p}/meta" | jq -e .fqn >/dev/null - done - - - name: Install Nexus CLI - run: | - curl -fsSL https://raw.githubusercontent.com/Talus-Network/homebrew-tap/main/install.sh | bash - echo "$HOME/.nexus/bin" >> "$GITHUB_PATH" - - - name: Register / update tools on Nexus mainnet - run: bash tools/${CRATE}/deploy/register.sh "$URL" mainnet diff --git a/.github/workflows/deploy-payments-stripe-testnet.yml b/.github/workflows/deploy-payments-stripe-testnet.yml deleted file mode 100644 index 59f7a38..0000000 --- a/.github/workflows/deploy-payments-stripe-testnet.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: deploy-payments-stripe-testnet - -on: - push: - branches: [main] - paths: - - "tools/payments-stripe/**" - - ".github/workflows/deploy-payments-stripe-testnet.yml" - workflow_dispatch: - -permissions: - contents: read - id-token: write - -env: - PROJECT_ID: talus-tools-testnet - REGION: us-central1 - REPO: nexus-tools - CRATE: payments-stripe - SERVICE: payments-stripe-testnet - -jobs: - build-deploy-register: - runs-on: ubuntu-latest - environment: testnet - steps: - - uses: actions/checkout@v4 - - - id: auth - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER_TESTNET }} - service_account: github-actions@${{ env.PROJECT_ID }}.iam.gserviceaccount.com - - - uses: google-github-actions/setup-gcloud@v2 - - - name: Configure Docker - run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet - - - name: Build and push image - env: - SHA: ${{ github.sha }} - run: | - IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${CRATE}:${SHA}" - docker build -f tools/${CRATE}/deploy/Dockerfile -t "$IMAGE" . - docker push "$IMAGE" - - - name: Deploy to Cloud Run - env: - SHA: ${{ github.sha }} - run: | - export REGISTRY="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}" - envsubst < tools/${CRATE}/deploy/cloud-run.testnet.yaml > /tmp/svc.yaml - gcloud run services replace /tmp/svc.yaml --region "$REGION" --quiet - URL=$(gcloud run services describe "$SERVICE" --region "$REGION" --format='value(status.url)') - echo "URL=$URL" >> "$GITHUB_ENV" - - - name: Smoke test - run: | - for p in $(jq -r '.[]' tools/${CRATE}/paths.json); do - curl -fsS "${URL}${p}/health" - curl -fsS "${URL}${p}/meta" | jq -e .fqn >/dev/null - done - - - name: Install Nexus CLI - run: | - curl -fsSL https://raw.githubusercontent.com/Talus-Network/homebrew-tap/main/install.sh | bash - echo "$HOME/.nexus/bin" >> "$GITHUB_PATH" - - - name: Register / update tools on Nexus testnet - run: bash tools/${CRATE}/deploy/register.sh "$URL" testnet diff --git a/offchain/tools/payments-stripe/.env.example b/offchain/tools/payments-stripe/.env.example new file mode 100644 index 0000000..67748a1 --- /dev/null +++ b/offchain/tools/payments-stripe/.env.example @@ -0,0 +1,14 @@ +# payments-stripe — required environment variables. +# +# Copy to `.env` for local dev (this file MUST stay in source control; +# `.env` MUST be gitignored). In production these come from Cloud Run +# `secretKeyRef` bindings to GCP Secret Manager, configured by the +# operator out-of-band (the deploy pipeline does NOT provision the key). +# +# The tool exits 1 at startup if STRIPE_API_KEY is unset, empty, or does +# not start with sk_test_ / sk_live_ / rk_test_ / rk_live_. + +STRIPE_API_KEY=sk_test_replace_with_real_key + +# Optional. Defaults to "info". +RUST_LOG=info diff --git a/tools/payments-stripe/Cargo.toml b/offchain/tools/payments-stripe/Cargo.toml similarity index 72% rename from tools/payments-stripe/Cargo.toml rename to offchain/tools/payments-stripe/Cargo.toml index 6c9f775..7b6c8e2 100644 --- a/tools/payments-stripe/Cargo.toml +++ b/offchain/tools/payments-stripe/Cargo.toml @@ -12,13 +12,22 @@ authors.workspace = true keywords.workspace = true categories.workspace = true +[[bin]] +name = "payments-stripe" +path = "src/main.rs" + [dependencies] -thiserror.workspace = true -tokio.workspace = true +anyhow.workspace = true +dotenvy.workspace = true +env_logger.workspace = true +log.workspace = true reqwest = { workspace = true, features = ["json"] } -serde_json.workspace = true -serde.workspace = true schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +zeroize.workspace = true # === Nexus deps === nexus-toolkit.workspace = true @@ -26,3 +35,7 @@ nexus-sdk.workspace = true [dev-dependencies] mockito.workspace = true + +[build-dependencies] +serde_json.workspace = true +toml = "0.8" diff --git a/tools/payments-stripe/README.md b/offchain/tools/payments-stripe/README.md similarity index 65% rename from tools/payments-stripe/README.md rename to offchain/tools/payments-stripe/README.md index 4a098b2..ca50090 100644 --- a/tools/payments-stripe/README.md +++ b/offchain/tools/payments-stripe/README.md @@ -1,36 +1,59 @@ # Stripe tools for Nexus A set of stateless Nexus Tools that wrap the [Stripe REST -API](https://stripe.com/docs/api). Each tool is a single endpoint; pass -your Stripe secret key per request via the `api_key` input port. +API](https://stripe.com/docs/api). Each tool is a single endpoint. ## Credential handling -- **Never** put a real Stripe key in a DAG `default_values` entry — DAG - data is committed to Sui and is public + permanent. Use placeholders - like `$STRIPE_API_KEY` and have the Leader inject the real value at - execution time from its secret store. -- **Tests use `sk_test_...` keys only.** Real `sk_live_...` material - must never enter source, fixtures, or logs. -- The Tool process holds no Stripe credentials between requests. The - `api_key` lives only in the `Input` struct's scope inside `invoke()`. +- **The tool reads `STRIPE_API_KEY` once from the process environment at + startup.** Sources: + - Production (Cloud Run): mounted via `secretKeyRef` from GCP Secret + Manager, configured by the operator out-of-band (the deploy pipeline + does NOT provision the secret). + - Local dev: copy `.env.example` to `.env` and set + `STRIPE_API_KEY=sk_test_…`. +- **The credential never appears on any `Input` struct.** Tool inputs flow + through the Nexus DAG on Sui as plaintext — anything on `Input` is + effectively published on-chain. The skill's auditor will refuse to mark + the tool ready if any `Input` field is credential-shaped. +- The credential is wrapped in `zeroize::Zeroizing` (heap-zeroed + on drop). The struct hand-implements `Debug` to print ``. +- The tool exits 1 at startup if `STRIPE_API_KEY` is unset, empty, or does + not start with one of `sk_test_`, `sk_live_`, `rk_test_`, `rk_live_`. +- **Tests use `sk_test_…` keys only.** Real `sk_live_…` material must + never enter source, fixtures, or logs. + +## Environment variables + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `STRIPE_API_KEY` | **yes** | — | Stripe secret (`sk_test_…` for staging/test, `sk_live_…` for prod). Validated at startup; the tool refuses to boot without it. | +| `RUST_LOG` | no | `info` | env_logger filter. | +| `BIND_ADDR` | no | `127.0.0.1:8080` | The toolkit's HTTP bind address. | ## Idempotency Every write endpoint accepts an optional `idempotency_key`. Generate a UUID per logical retry-bucket in your DAG. Stripe guarantees identical responses for identical idempotency keys; reusing the same key on retry -prevents double-charges. +prevents double-charges. `idempotency_key` is per-call dedup data — NOT a +credential — so it stays on `Input`. + +## FQN versioning + +All six FQNs are threaded through `env!("TOOL_FQN_VERSION")` in +`build.rs`; CI sets `TOOL_FQN_VERSION` from the tool's subtree git hash, +so any source change auto-bumps the version on the next deploy. Local +builds default to `@1`. --- -# `xyz.taluslabs.payments.stripe.create-payment-intent@1` +# `xyz.taluslabs.payments.stripe.create-payment-intent@` Creates a [PaymentIntent](https://stripe.com/docs/api/payment_intents/create). ## Input -- **`api_key`: [`String`]** — Stripe secret key (`sk_test_...` for staging, `sk_live_...` for prod). Sourced by the Leader at execution time. - **`idempotency_key`: [`String`] (optional)** — `Idempotency-Key` header value. Generate a UUID per retry-bucket. - **`amount`: [`i64`]** — Amount in the smallest currency unit (e.g. cents for USD). - **`currency`: [`String`]** — ISO-4217 currency code (lowercase, e.g. `usd`). @@ -55,13 +78,12 @@ Creates a [PaymentIntent](https://stripe.com/docs/api/payment_intents/create). --- -# `xyz.taluslabs.payments.stripe.get-payment-intent@1` +# `xyz.taluslabs.payments.stripe.get-payment-intent@` Retrieves a [PaymentIntent](https://stripe.com/docs/api/payment_intents/retrieve) by id. ## Input -- **`api_key`: [`String`]** — Stripe secret key. - **`payment_intent_id`: [`String`]** — `pi_…` to look up. ## Output Variants & Ports @@ -78,13 +100,12 @@ Retrieves a [PaymentIntent](https://stripe.com/docs/api/payment_intents/retrieve --- -# `xyz.taluslabs.payments.stripe.confirm-payment-intent@1` +# `xyz.taluslabs.payments.stripe.confirm-payment-intent@` [Confirms](https://stripe.com/docs/api/payment_intents/confirm) a PaymentIntent. ## Input -- **`api_key`: [`String`]** - **`idempotency_key`: [`String`] (optional)** - **`payment_intent_id`: [`String`]** — `pi_…` to confirm. - **`payment_method`: [`String`] (optional)** — `pm_…` to attach (e.g. `pm_card_visa` in test mode). @@ -102,13 +123,12 @@ Retrieves a [PaymentIntent](https://stripe.com/docs/api/payment_intents/retrieve --- -# `xyz.taluslabs.payments.stripe.create-customer@1` +# `xyz.taluslabs.payments.stripe.create-customer@` Creates a [Customer](https://stripe.com/docs/api/customers/create). ## Input -- **`api_key`: [`String`]** - **`idempotency_key`: [`String`] (optional)** - **`email`: [`String`] (optional)** - **`name`: [`String`] (optional)** @@ -126,13 +146,13 @@ Creates a [Customer](https://stripe.com/docs/api/customers/create). --- -# `xyz.taluslabs.payments.stripe.get-balance@1` +# `xyz.taluslabs.payments.stripe.get-balance@` Reads the platform [Balance](https://stripe.com/docs/api/balance/balance_retrieve). ## Input -- **`api_key`: [`String`]** +No input ports — credentials come from env. ## Output Variants & Ports @@ -145,13 +165,12 @@ Reads the platform [Balance](https://stripe.com/docs/api/balance/balance_retriev --- -# `xyz.taluslabs.payments.stripe.list-charges@1` +# `xyz.taluslabs.payments.stripe.list-charges@` Lists [Charges](https://stripe.com/docs/api/charges/list). ## Input -- **`api_key`: [`String`]** - **`limit`: [`i64`] (optional)** — Page size (1–100, Stripe default 10). - **`customer`: [`String`] (optional)** — Filter to a specific customer id. - **`starting_after`: [`String`] (optional)** — Cursor for pagination (charge id from the previous page). diff --git a/offchain/tools/payments-stripe/build.rs b/offchain/tools/payments-stripe/build.rs new file mode 100644 index 0000000..07c24d8 --- /dev/null +++ b/offchain/tools/payments-stripe/build.rs @@ -0,0 +1,50 @@ +use std::{env, fs, path::PathBuf}; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let tools_json_path = manifest_dir.join("tools.json"); + let cargo_toml_path = manifest_dir.join("Cargo.toml"); + + println!("cargo::rerun-if-changed={}", tools_json_path.display()); + println!("cargo::rerun-if-changed={}", cargo_toml_path.display()); + + let tools_json: serde_json::Value = serde_json::from_str( + &fs::read_to_string(&tools_json_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", tools_json_path.display())), + ) + .expect("tools.json must be valid JSON"); + + let command = tools_json["command"] + .as_str() + .expect("tools.json must have command"); + + let cargo_toml: toml::Value = toml::from_str( + &fs::read_to_string(&cargo_toml_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", cargo_toml_path.display())), + ) + .expect("Cargo.toml must be valid TOML"); + + let bin_name = cargo_toml + .get("bin") + .and_then(|b| b.as_array()) + .and_then(|bins| bins.first()) + .and_then(|b| b.get("name")) + .and_then(|n| n.as_str()); + + match bin_name { + Some(name) if name == command => {} + Some(name) => panic!( + "Binary name mismatch: Cargo.toml [[bin]] name = \"{name}\" \ + but tools.json command = \"{command}\". They must match." + ), + None => panic!( + "Cargo.toml has no [[bin]] section but tools.json specifies \ + command = \"{command}\". Add: [[bin]]\nname = \"{command}\"" + ), + } + + // FQN version is set by CI via Docker build-arg; defaults to "1" locally. + let version = env::var("TOOL_FQN_VERSION").unwrap_or_else(|_| "1".to_string()); + println!("cargo:rustc-env=TOOL_FQN_VERSION={version}"); + println!("cargo:rerun-if-env-changed=TOOL_FQN_VERSION"); +} diff --git a/tools/payments-stripe/src/error.rs b/offchain/tools/payments-stripe/src/error.rs similarity index 100% rename from tools/payments-stripe/src/error.rs rename to offchain/tools/payments-stripe/src/error.rs diff --git a/offchain/tools/payments-stripe/src/main.rs b/offchain/tools/payments-stripe/src/main.rs new file mode 100644 index 0000000..708896d --- /dev/null +++ b/offchain/tools/payments-stripe/src/main.rs @@ -0,0 +1,49 @@ +#![doc = include_str!("../README.md")] +#![allow(clippy::large_enum_variant)] + +use nexus_toolkit::bootstrap; + +mod error; +mod stripe_client; +mod tools; + +fn main() { + // Install env_logger before anything else so dotenv/credential paths + // emit through `log::*`; `bootstrap!`'s own `try_init()` becomes a no-op. + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp_millis() + .init(); + + // `--meta` is an introspection mode that emits the tool's FQN/URL/ + // schema list without standing up the runtime. CI's prepare step + // invokes it from a fresh image with no env, so it must not require + // runtime credentials. `nexus_toolkit::bootstrap!` handles --meta + // and exits before any HTTP call is made. + let meta_only = std::env::args().any(|a| a == "--meta"); + + if !meta_only { + // dotenv + credential validation run single-threaded — `set_var` + // is unsound from a multi-threaded process. `main` is the only + // exit site. + stripe_client::load_dotenv_if_present(); + if let Err(reason) = stripe_client::validate_credentials_at_startup() { + log::error!("{} {reason}", stripe_client::ENV_API_KEY); + std::process::exit(1); + } + } + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime") + .block_on(async { + bootstrap!([ + tools::create_payment_intent::CreatePaymentIntent, + tools::get_payment_intent::GetPaymentIntent, + tools::confirm_payment_intent::ConfirmPaymentIntent, + tools::create_customer::CreateCustomer, + tools::get_balance::GetBalance, + tools::list_charges::ListCharges, + ]) + }); +} diff --git a/offchain/tools/payments-stripe/src/stripe_client.rs b/offchain/tools/payments-stripe/src/stripe_client.rs new file mode 100644 index 0000000..e490f8f --- /dev/null +++ b/offchain/tools/payments-stripe/src/stripe_client.rs @@ -0,0 +1,326 @@ +//! Stateless Stripe HTTP client. +//! +//! Credential model (audit checks C1, C7, C8, C9, C10): +//! +//! - `STRIPE_API_KEY` is read once at startup via [`from_env`]. The +//! bearer is wrapped in [`zeroize::Zeroizing`] so the heap +//! buffer is wiped on drop. The struct hand-implements `Debug` to +//! print `` — never `#[derive(Debug)]`. +//! - The credential never appears on the `Input` struct of any tool — +//! tool inputs flow through the Nexus DAG on Sui as plaintext. +//! - The struct's only mutable per-request field is the optional +//! `Idempotency-Key` (Stripe-style writes); cheap to clone, no +//! shared state. +//! - In Cloud Run the env var is mounted by Secret Manager via a +//! `secretKeyRef` binding configured by the operator (out-of-band). +//! The deploy pipeline does NOT provision the upstream API key. +//! +//! Canonical reference: `offchain/tools/memory-memwal/src/client.rs`. + +use { + crate::{ + error::{try_parse_api_error, StripeErrorKind, StripeErrorResponse}, + tools::STRIPE_API_BASE, + }, + reqwest::{Client, RequestBuilder}, + serde::{de::DeserializeOwned, Serialize}, + std::sync::{Arc, Once, OnceLock}, + zeroize::Zeroizing, +}; + +/// Env var holding the Stripe secret key (`sk_test_…` or `sk_live_…`). +/// Mounted in Cloud Run via `secretKeyRef`; configured by the operator +/// out-of-band, not by the deploy pipeline. +pub(crate) const ENV_API_KEY: &str = "STRIPE_API_KEY"; + +/// One-shot guard so the "missing key" warning fires once even though +/// `from_env` runs once per registered tool (six tools = six `new()` calls). +static WARN_MISSING_KEY: Once = Once::new(); + +/// Process-wide HTTP client shared across every `StripeClient` so the +/// connection pool, TLS session cache, and HTTP/2 multiplexing survive +/// across `invoke` calls. `reqwest::Client` clone is a cheap Arc bump. +static SHARED_HTTP: OnceLock = OnceLock::new(); + +fn shared_http() -> Client { + SHARED_HTTP + .get_or_init(|| { + Client::builder() + .user_agent("nexus-sdk-payments-stripe/1.0") + .build() + .expect("Failed to create HTTP client") + }) + .clone() +} + +/// Load `/.env` if present. Cwd-only (no parent walk) so a planted +/// `.env` above the binary's cwd cannot influence the process. Existing +/// exports always win. MUST be called before the tokio runtime is built — +/// `set_var` is unsound from a multi-threaded process. +pub(crate) fn load_dotenv_if_present() { + let candidate = match std::env::current_dir() { + Ok(d) => d.join(".env"), + Err(e) => { + log::warn!("could not read cwd while looking for .env: {e}"); + return; + } + }; + if !candidate.is_file() { + return; + } + match dotenvy::from_path(&candidate) { + Ok(()) => log::info!("loaded env vars from {}", candidate.display()), + Err(e) => log::warn!("failed to load {}: {e}", candidate.display()), + } +} + +/// Classification of the Stripe key env var. Hand-written `Debug` redacts +/// the secret — never `derive` Debug on this type. +enum KeyValidation { + Ok(Zeroizing), + Missing, + Invalid(String), +} + +impl std::fmt::Debug for KeyValidation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ok(_) => write!(f, "Ok()"), + Self::Missing => write!(f, "Missing"), + Self::Invalid(reason) => f.debug_tuple("Invalid").field(reason).finish(), + } + } +} + +/// Pure classifier — no I/O, no exits, deterministic. +fn classify_key(value: Option<&str>) -> KeyValidation { + let raw = match value { + Some(s) if !s.is_empty() => s, + _ => return KeyValidation::Missing, + }; + // Stripe secret keys are prefixed `sk_test_` (test mode) or + // `sk_live_` (production). Restricted keys use `rk_test_` / `rk_live_`. + // Reject anything else as a typo / wrong env var. + let valid_prefix = ["sk_test_", "sk_live_", "rk_test_", "rk_live_"] + .iter() + .any(|p| raw.starts_with(p)); + if !valid_prefix { + return KeyValidation::Invalid( + "is set but does not start with a recognized Stripe prefix \ + (sk_test_, sk_live_, rk_test_, rk_live_)." + .to_string(), + ); + } + if raw.len() < 32 { + return KeyValidation::Invalid(format!( + "is set but is shorter than expected ({} chars). Real Stripe \ + keys are ≥32 chars after the prefix.", + raw.len() + )); + } + KeyValidation::Ok(Zeroizing::new(raw.to_string())) +} + +/// Eager startup-time key validation. Without this, a malformed key would +/// only surface on the first `/invoke` (well after `server-start` reports +/// "Ready"). `main` is the only `process::exit` site. +pub(crate) fn validate_credentials_at_startup() -> Result<(), String> { + read_validated_api_key().map(|_| ()) +} + +/// `Ok(Some(key))` valid; `Ok(None)` unset (warn-and-continue); `Err(reason)` +/// malformed. `main` exits on `Err`; `from_env` downgrades it to a log so a +/// mid-process key rotation doesn't kill in-flight requests. +fn read_validated_api_key() -> Result>, String> { + // `env::var` returns a fresh `String` — wrap it immediately so the + // intermediate copy is also zeroed on drop. + let raw = std::env::var(ENV_API_KEY).ok().map(Zeroizing::new); + match classify_key(raw.as_deref().map(|z| z.as_str())) { + KeyValidation::Ok(k) => Ok(Some(k)), + KeyValidation::Missing => { + WARN_MISSING_KEY.call_once(|| { + log::warn!( + "{ENV_API_KEY} is not set — every invoke will fail \ + upstream with 401 until this env var is exported in \ + the process environment." + ); + }); + Ok(None) + } + KeyValidation::Invalid(reason) => Err(reason), + } +} + +#[derive(Clone)] +pub struct StripeClient { + client: Arc, + base_url: String, + /// Stripe secret key read once at startup from `ENV_API_KEY`. Wrapped + /// in `Zeroizing` so heap buffers are wiped on drop. `None` if the + /// env var was missing or malformed — `invoke()` calls will fail + /// upstream with 401, which is the right signal to the operator. + bearer: Option>>, + /// Per-request idempotency key (Stripe-style writes). Set via + /// `.with_idempotency(...)` on the cheap-cloned client. + idempotency_key: Option, +} + +// Hand-written Debug — NEVER derive on a type holding a credential. +impl std::fmt::Debug for StripeClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StripeClient") + .field("base_url", &self.base_url) + .field( + "bearer", + &if self.bearer.is_some() { + "" + } else { + "" + }, + ) + .field("idempotency_key", &self.idempotency_key) + .finish() + } +} + +impl StripeClient { + /// Production constructor: reads `STRIPE_API_KEY` from the process + /// env and wraps it in `Zeroizing`. A missing/malformed key logs but + /// does not error here — `main`'s startup validation is the + /// fail-fast site. + pub fn from_env() -> Result { + let bearer = match read_validated_api_key() { + Ok(Some(k)) => Some(Arc::new(k)), + Ok(None) => None, + Err(reason) => { + log::error!("{ENV_API_KEY} {reason}"); + None + } + }; + Ok(Self { + client: Arc::new(shared_http()), + base_url: STRIPE_API_BASE.to_string(), + bearer, + idempotency_key: None, + }) + } + + /// Test-only constructor. Accepts a base URL (HTTP loopback for + /// mockito) and a bearer string directly, bypassing env validation. + #[cfg(test)] + pub(crate) fn for_testing(base_url: &str, bearer: &str) -> Self { + Self { + client: Arc::new( + Client::builder() + .user_agent("nexus-sdk-payments-stripe/1.0") + .build() + .expect("failed to build test HTTP client"), + ), + base_url: base_url.to_string(), + bearer: Some(Arc::new(Zeroizing::new(bearer.to_string()))), + idempotency_key: None, + } + } + + /// Attach an `Idempotency-Key` header for the next request. Required + /// by Stripe writes; harmless on reads. + #[must_use] + pub fn with_idempotency(mut self, key: &str) -> Self { + self.idempotency_key = Some(key.to_string()); + self + } + + pub async fn get(&self, endpoint: &str) -> Result + where + T: DeserializeOwned, + { + let req = self.apply_headers(self.client.get(self.url(endpoint))); + self.send(req).await + } + + /// Stripe uses `application/x-www-form-urlencoded` for write bodies, + /// not JSON. Pass a `serde_urlencoded`-compatible value. + pub async fn post_form(&self, endpoint: &str, body: &B) -> Result + where + T: DeserializeOwned, + B: Serialize + ?Sized, + { + let req = self.apply_headers(self.client.post(self.url(endpoint)).form(body)); + self.send(req).await + } + + fn url(&self, endpoint: &str) -> String { + format!( + "{}/{}", + self.base_url.trim_end_matches('/'), + endpoint.trim_start_matches('/') + ) + } + + fn apply_headers(&self, mut req: RequestBuilder) -> RequestBuilder { + if let Some(ref bearer) = self.bearer { + req = req.bearer_auth(bearer.as_str()); + } + if let Some(ref key) = self.idempotency_key { + req = req.header("Idempotency-Key", key); + } + req + } + + async fn send(&self, req: RequestBuilder) -> Result + where + T: DeserializeOwned, + { + let response = match req.send().await { + Ok(r) => r, + Err(e) => { + return Err(StripeErrorResponse { + reason: format!("Network error: {}", e), + kind: StripeErrorKind::from_network_error(&e), + status_code: Some(0), + }); + } + }; + + let status = response.status(); + let text = match response.text().await { + Ok(t) => t, + Err(e) => { + return Err(StripeErrorResponse { + reason: format!("Failed to read response: {}", e), + kind: StripeErrorKind::Parse, + status_code: None, + }); + } + }; + + if !status.is_success() { + if let Some(parsed) = try_parse_api_error(&text, status.as_u16()) { + return Err(parsed); + } + return Err(StripeErrorResponse { + reason: format!("Stripe API error ({}): {}", status, truncate(&text, 512)), + kind: StripeErrorKind::from_status_code(status.as_u16()), + status_code: Some(status.as_u16()), + }); + } + + serde_json::from_str::(&text).map_err(|e| StripeErrorResponse { + reason: format!("Failed to parse JSON: {}", e), + kind: StripeErrorKind::Parse, + status_code: None, + }) + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) + } +} diff --git a/tools/payments-stripe/src/tools/confirm_payment_intent.rs b/offchain/tools/payments-stripe/src/tools/confirm_payment_intent.rs similarity index 87% rename from tools/payments-stripe/src/tools/confirm_payment_intent.rs rename to offchain/tools/payments-stripe/src/tools/confirm_payment_intent.rs index bc91586..dc8f7c4 100644 --- a/tools/payments-stripe/src/tools/confirm_payment_intent.rs +++ b/offchain/tools/payments-stripe/src/tools/confirm_payment_intent.rs @@ -1,4 +1,6 @@ -//! # `xyz.taluslabs.payments.stripe.confirm-payment-intent@1` +//! # `xyz.taluslabs.payments.stripe.confirm-payment-intent@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. use { crate::{error::StripeErrorKind, stripe_client::StripeClient}, @@ -11,7 +13,6 @@ use { #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Input { - pub api_key: String, pub idempotency_key: Option, pub payment_intent_id: String, pub payment_method: Option, @@ -58,12 +59,18 @@ impl NexusTool for ConfirmPaymentIntent { async fn new() -> Self { Self { - client: StripeClient::new(None), + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), } } fn fqn() -> ToolFqn { - fqn!("xyz.taluslabs.payments.stripe.confirm-payment-intent@1") + fqn!(concat!( + "xyz.taluslabs.payments.stripe.confirm-payment-intent@", + env!("TOOL_FQN_VERSION") + )) } fn path() -> &'static str { @@ -96,10 +103,10 @@ impl NexusTool for ConfirmPaymentIntent { } let endpoint = format!("v1/payment_intents/{}/confirm", input.payment_intent_id); - let mut client = self.client.clone().with_auth(&input.api_key); - if let Some(k) = &input.idempotency_key { - client = client.with_idempotency(k); - } + let client = match &input.idempotency_key { + Some(k) => self.client.clone().with_idempotency(k), + None => self.client.clone(), + }; match client .post_form::(&endpoint, &form) @@ -128,13 +135,12 @@ mod tests { async fn create_server_and_tool() -> (mockito::ServerGuard, ConfirmPaymentIntent) { let server = Server::new_async().await; - let client = StripeClient::new(Some(&server.url())); + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); (server, ConfirmPaymentIntent { client }) } fn test_input() -> Input { Input { - api_key: "sk_test_FAKE".to_string(), idempotency_key: None, payment_intent_id: "pi_test_123".to_string(), payment_method: Some("pm_card_visa".to_string()), diff --git a/tools/payments-stripe/src/tools/create_customer.rs b/offchain/tools/payments-stripe/src/tools/create_customer.rs similarity index 86% rename from tools/payments-stripe/src/tools/create_customer.rs rename to offchain/tools/payments-stripe/src/tools/create_customer.rs index 84c3413..251fce1 100644 --- a/tools/payments-stripe/src/tools/create_customer.rs +++ b/offchain/tools/payments-stripe/src/tools/create_customer.rs @@ -1,4 +1,6 @@ -//! # `xyz.taluslabs.payments.stripe.create-customer@1` +//! # `xyz.taluslabs.payments.stripe.create-customer@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. use { crate::{error::StripeErrorKind, stripe_client::StripeClient}, @@ -11,7 +13,6 @@ use { #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Input { - pub api_key: String, pub idempotency_key: Option, pub email: Option, pub name: Option, @@ -52,12 +53,18 @@ impl NexusTool for CreateCustomer { async fn new() -> Self { Self { - client: StripeClient::new(None), + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), } } fn fqn() -> ToolFqn { - fqn!("xyz.taluslabs.payments.stripe.create-customer@1") + fqn!(concat!( + "xyz.taluslabs.payments.stripe.create-customer@", + env!("TOOL_FQN_VERSION") + )) } fn path() -> &'static str { @@ -84,10 +91,10 @@ impl NexusTool for CreateCustomer { form.push(("description", d.clone())); } - let mut client = self.client.clone().with_auth(&input.api_key); - if let Some(k) = &input.idempotency_key { - client = client.with_idempotency(k); - } + let client = match &input.idempotency_key { + Some(k) => self.client.clone().with_idempotency(k), + None => self.client.clone(), + }; match client .post_form::("v1/customers", &form) @@ -116,7 +123,7 @@ mod tests { async fn create_server_and_tool() -> (mockito::ServerGuard, CreateCustomer) { let server = Server::new_async().await; - let client = StripeClient::new(Some(&server.url())); + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); (server, CreateCustomer { client }) } @@ -139,7 +146,6 @@ mod tests { let result = tool .invoke(Input { - api_key: "sk_test_FAKE".to_string(), idempotency_key: None, email: Some("test@example.com".to_string()), name: Some("Test User".to_string()), @@ -176,7 +182,6 @@ mod tests { let result = tool .invoke(Input { - api_key: "sk_test_BAD".to_string(), idempotency_key: None, email: Some("test@example.com".to_string()), name: None, diff --git a/tools/payments-stripe/src/tools/create_payment_intent.rs b/offchain/tools/payments-stripe/src/tools/create_payment_intent.rs similarity index 91% rename from tools/payments-stripe/src/tools/create_payment_intent.rs rename to offchain/tools/payments-stripe/src/tools/create_payment_intent.rs index f30fa57..c5ef901 100644 --- a/tools/payments-stripe/src/tools/create_payment_intent.rs +++ b/offchain/tools/payments-stripe/src/tools/create_payment_intent.rs @@ -1,9 +1,10 @@ -//! # `xyz.taluslabs.payments.stripe.create-payment-intent@1` +//! # `xyz.taluslabs.payments.stripe.create-payment-intent@` //! //! Creates a Stripe PaymentIntent. //! -//! Stateless: holds only a stateless connection pool. Credential -//! (`api_key`) is supplied per request via `Input`. +//! Credentials come from the `STRIPE_API_KEY` env var at startup (see +//! `src/stripe_client.rs::from_env`). They are NEVER fields on `Input` — +//! tool inputs flow through the Nexus DAG on Sui as plaintext. use { crate::{error::StripeErrorKind, stripe_client::StripeClient}, @@ -16,9 +17,6 @@ use { #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Input { - /// Stripe secret key. NEVER put this in DAG `default_values` — the - /// Leader supplies it at execution time. - pub api_key: String, /// Optional Idempotency-Key for safe retry. pub idempotency_key: Option, /// Amount in the smallest currency unit (cents for USD). @@ -68,12 +66,18 @@ impl NexusTool for CreatePaymentIntent { async fn new() -> Self { Self { - client: StripeClient::new(None), + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), } } fn fqn() -> ToolFqn { - fqn!("xyz.taluslabs.payments.stripe.create-payment-intent@1") + fqn!(concat!( + "xyz.taluslabs.payments.stripe.create-payment-intent@", + env!("TOOL_FQN_VERSION") + )) } fn path() -> &'static str { @@ -116,10 +120,10 @@ impl NexusTool for CreatePaymentIntent { form.push(("description", d.clone())); } - let mut client = self.client.clone().with_auth(&input.api_key); - if let Some(k) = &input.idempotency_key { - client = client.with_idempotency(k); - } + let client = match &input.idempotency_key { + Some(k) => self.client.clone().with_idempotency(k), + None => self.client.clone(), + }; match client .post_form::("v1/payment_intents", &form) @@ -150,13 +154,12 @@ mod tests { async fn create_server_and_tool() -> (mockito::ServerGuard, CreatePaymentIntent) { let server = Server::new_async().await; - let client = StripeClient::new(Some(&server.url())); + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE_FOR_TESTS_ONLY"); (server, CreatePaymentIntent { client }) } fn test_input() -> Input { Input { - api_key: "sk_test_FAKE_FOR_TESTS_ONLY".to_string(), idempotency_key: Some("test-idempotency-key-001".to_string()), amount: 2000, currency: "usd".to_string(), diff --git a/tools/payments-stripe/src/tools/get_balance.rs b/offchain/tools/payments-stripe/src/tools/get_balance.rs similarity index 77% rename from tools/payments-stripe/src/tools/get_balance.rs rename to offchain/tools/payments-stripe/src/tools/get_balance.rs index afec74c..5b9077d 100644 --- a/tools/payments-stripe/src/tools/get_balance.rs +++ b/offchain/tools/payments-stripe/src/tools/get_balance.rs @@ -1,4 +1,6 @@ -//! # `xyz.taluslabs.payments.stripe.get-balance@1` +//! # `xyz.taluslabs.payments.stripe.get-balance@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. use { crate::{error::StripeErrorKind, stripe_client::StripeClient, tools::models::BalanceAmount}, @@ -10,9 +12,7 @@ use { #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub(crate) struct Input { - pub api_key: String, -} +pub(crate) struct Input {} #[derive(Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -45,12 +45,18 @@ impl NexusTool for GetBalance { async fn new() -> Self { Self { - client: StripeClient::new(None), + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), } } fn fqn() -> ToolFqn { - fqn!("xyz.taluslabs.payments.stripe.get-balance@1") + fqn!(concat!( + "xyz.taluslabs.payments.stripe.get-balance@", + env!("TOOL_FQN_VERSION") + )) } fn path() -> &'static str { @@ -65,9 +71,8 @@ impl NexusTool for GetBalance { Ok(StatusCode::OK) } - async fn invoke(&self, input: Self::Input) -> Self::Output { - let client = self.client.clone().with_auth(&input.api_key); - match client.get::("v1/balance").await { + async fn invoke(&self, _input: Self::Input) -> Self::Output { + match self.client.get::("v1/balance").await { Ok(b) => Output::Ok { available: b.available, pending: b.pending, @@ -90,7 +95,7 @@ mod tests { async fn create_server_and_tool() -> (mockito::ServerGuard, GetBalance) { let server = Server::new_async().await; - let client = StripeClient::new(Some(&server.url())); + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); (server, GetBalance { client }) } @@ -110,11 +115,7 @@ mod tests { .create_async() .await; - let result = tool - .invoke(Input { - api_key: "sk_test_FAKE".to_string(), - }) - .await; + let result = tool.invoke(Input {}).await; match result { Output::Ok { available, pending } => { assert_eq!(available.len(), 1); diff --git a/tools/payments-stripe/src/tools/get_payment_intent.rs b/offchain/tools/payments-stripe/src/tools/get_payment_intent.rs similarity index 88% rename from tools/payments-stripe/src/tools/get_payment_intent.rs rename to offchain/tools/payments-stripe/src/tools/get_payment_intent.rs index cd5a084..3dac6bc 100644 --- a/tools/payments-stripe/src/tools/get_payment_intent.rs +++ b/offchain/tools/payments-stripe/src/tools/get_payment_intent.rs @@ -1,4 +1,6 @@ -//! # `xyz.taluslabs.payments.stripe.get-payment-intent@1` +//! # `xyz.taluslabs.payments.stripe.get-payment-intent@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. use { crate::{error::StripeErrorKind, stripe_client::StripeClient}, @@ -11,7 +13,6 @@ use { #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Input { - pub api_key: String, pub payment_intent_id: String, } @@ -53,12 +54,18 @@ impl NexusTool for GetPaymentIntent { async fn new() -> Self { Self { - client: StripeClient::new(None), + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), } } fn fqn() -> ToolFqn { - fqn!("xyz.taluslabs.payments.stripe.get-payment-intent@1") + fqn!(concat!( + "xyz.taluslabs.payments.stripe.get-payment-intent@", + env!("TOOL_FQN_VERSION") + )) } fn path() -> &'static str { @@ -83,9 +90,8 @@ impl NexusTool for GetPaymentIntent { } let endpoint = format!("v1/payment_intents/{}", input.payment_intent_id); - let client = self.client.clone().with_auth(&input.api_key); - match client.get::(&endpoint).await { + match self.client.get::(&endpoint).await { Ok(pi) => Output::Ok { id: pi.id, status: pi.status, @@ -111,7 +117,7 @@ mod tests { async fn create_server_and_tool() -> (mockito::ServerGuard, GetPaymentIntent) { let server = Server::new_async().await; - let client = StripeClient::new(Some(&server.url())); + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); (server, GetPaymentIntent { client }) } @@ -137,7 +143,6 @@ mod tests { let result = tool .invoke(Input { - api_key: "sk_test_FAKE".to_string(), payment_intent_id: "pi_test_123".to_string(), }) .await; @@ -171,7 +176,6 @@ mod tests { let result = tool .invoke(Input { - api_key: "sk_test_FAKE".to_string(), payment_intent_id: "pi_missing".to_string(), }) .await; @@ -189,7 +193,6 @@ mod tests { let (_, tool) = create_server_and_tool().await; let result = tool .invoke(Input { - api_key: "sk_test_FAKE".to_string(), payment_intent_id: "".to_string(), }) .await; diff --git a/tools/payments-stripe/src/tools/list_charges.rs b/offchain/tools/payments-stripe/src/tools/list_charges.rs similarity index 89% rename from tools/payments-stripe/src/tools/list_charges.rs rename to offchain/tools/payments-stripe/src/tools/list_charges.rs index 60177e0..5323017 100644 --- a/tools/payments-stripe/src/tools/list_charges.rs +++ b/offchain/tools/payments-stripe/src/tools/list_charges.rs @@ -1,4 +1,6 @@ -//! # `xyz.taluslabs.payments.stripe.list-charges@1` +//! # `xyz.taluslabs.payments.stripe.list-charges@` +//! +//! Credentials come from `STRIPE_API_KEY` env at startup; never on Input. use { crate::{error::StripeErrorKind, stripe_client::StripeClient, tools::models::ChargeSummary}, @@ -11,7 +13,6 @@ use { #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub(crate) struct Input { - pub api_key: String, pub limit: Option, pub customer: Option, pub starting_after: Option, @@ -48,12 +49,18 @@ impl NexusTool for ListCharges { async fn new() -> Self { Self { - client: StripeClient::new(None), + client: StripeClient::from_env().unwrap_or_else(|e| { + log::error!("payments-stripe configuration invalid: {e}"); + panic!("payments-stripe configuration invalid: {e}"); + }), } } fn fqn() -> ToolFqn { - fqn!("xyz.taluslabs.payments.stripe.list-charges@1") + fqn!(concat!( + "xyz.taluslabs.payments.stripe.list-charges@", + env!("TOOL_FQN_VERSION") + )) } fn path() -> &'static str { @@ -97,8 +104,7 @@ impl NexusTool for ListCharges { format!("v1/charges?{qs}") }; - let client = self.client.clone().with_auth(&input.api_key); - match client.get::(&endpoint).await { + match self.client.get::(&endpoint).await { Ok(r) => Output::Ok { charges: r.data, has_more: r.has_more, @@ -143,7 +149,7 @@ mod tests { async fn create_server_and_tool() -> (mockito::ServerGuard, ListCharges) { let server = Server::new_async().await; - let client = StripeClient::new(Some(&server.url())); + let client = StripeClient::for_testing(&server.url(), "sk_test_FAKE"); (server, ListCharges { client }) } @@ -168,7 +174,6 @@ mod tests { let result = tool .invoke(Input { - api_key: "sk_test_FAKE".to_string(), limit: Some(2), customer: None, starting_after: None, @@ -190,7 +195,6 @@ mod tests { let (_, tool) = create_server_and_tool().await; let result = tool .invoke(Input { - api_key: "sk_test_FAKE".to_string(), limit: Some(150), customer: None, starting_after: None, diff --git a/tools/payments-stripe/src/tools/mod.rs b/offchain/tools/payments-stripe/src/tools/mod.rs similarity index 100% rename from tools/payments-stripe/src/tools/mod.rs rename to offchain/tools/payments-stripe/src/tools/mod.rs diff --git a/tools/payments-stripe/src/tools/models.rs b/offchain/tools/payments-stripe/src/tools/models.rs similarity index 100% rename from tools/payments-stripe/src/tools/models.rs rename to offchain/tools/payments-stripe/src/tools/models.rs diff --git a/offchain/tools/payments-stripe/tools.json b/offchain/tools/payments-stripe/tools.json new file mode 100644 index 0000000..bdcfd30 --- /dev/null +++ b/offchain/tools/payments-stripe/tools.json @@ -0,0 +1,7 @@ +{ + "tool_name": "payments-stripe", + "command": "payments-stripe", + "environment": { + "RUST_LOG": "info" + } +} diff --git a/tools/payments-stripe/AUDIT.md b/tools/payments-stripe/AUDIT.md deleted file mode 100644 index 9e11513..0000000 --- a/tools/payments-stripe/AUDIT.md +++ /dev/null @@ -1,230 +0,0 @@ -# Audit: `payments-stripe` @ 815a8c1+wip - -- **Date:** 2026-05-15 -- **Auditor:** nexus-tool-auditor (executed inline by primary agent — project agent loader unavailable in this harness) -- **Kind:** off-chain -- **Severity floor:** low -- **Remediation mode:** report-only - -## Summary - -The crate cleanly passes every CRITICAL and HIGH check on the -`reference/security-checklist.md` matrix. 17/17 unit tests pass; clippy -is clean with `-D warnings`; `cargo fmt --check` is clean against -`nightly-2025-01-06`. The credential model matches the repo convention -(per-request `Input.api_key`; no env-var reads; no Debug derived on -Input; no upstream secrets mounted into Cloud Run). The tool is -stateless across invocations. - -Three MEDIUM items remain — they are not blockers for testnet but -should be addressed before mainnet promotion: (1) a Stripe error body -is included verbatim in the fallback `Output::Err::reason` and may -echo customer-supplied params; (2) some endpoints have thin -error-variant test coverage; (3) `NexusTool::timeout()` is not -overridden anywhere (defaults to 10 s, acceptable for current Stripe -endpoints but worth pinning explicitly). - -**Recommendation: ready-for-testnet.** Block mainnet pending the three -MEDIUM items below. - -## Findings - -### CRITICAL - -_No findings._ - -### HIGH - -_No findings._ - -### MEDIUM - -- **M-1 — Raw upstream body in fallback `Output::Err::reason`** - (`src/stripe_client.rs:127`) - - What: When the response body does not match Stripe's `{"error": - {...}}` envelope, the client falls back to - `format!("Stripe API error ({}): {}", status, truncate(&text, 512))`. - The raw body can contain card metadata (last 4), customer email, or - other parameters the caller submitted that Stripe echoes back in - plaintext. - - Why it matters: `Output::Err` is passed on-chain by Nexus; anything - in `reason` becomes public and permanent. Falls under C5 spirit - (defense in depth) even though Stripe error bodies don't directly - leak the api_key. - - Fix: Drop the raw body. Surface only the status code and the typed - `kind`: - - ```rust - return Err(StripeErrorResponse { - reason: format!("Stripe API error (status {})", status), - kind: StripeErrorKind::from_status_code(status.as_u16()), - status_code: Some(status.as_u16()), - }); - ``` - - If diagnostic detail is needed, write the body to a `tracing::warn!` - log line with field-level redaction, not to `reason`. - -- **M-2 — Thin error-variant test coverage in three endpoints** - - `tools/payments-stripe/src/tools/confirm_payment_intent.rs`: has - only a single validation test (`empty_id`); no test for - `card_error` / `auth_error` paths. - - `tools/payments-stripe/src/tools/get_balance.rs`: no error tests at - all (only `test_get_balance_success`). - - `tools/payments-stripe/src/tools/list_charges.rs`: covers - `limit_out_of_range` (local validation) but no upstream-error case. - - Why it matters: M3 says every endpoint has at least one error-variant - test. Stripe behavior changes; the regression net needs to catch a - drift in the error envelope on every endpoint. - - Fix: add a mockito-backed 401/402/404 test per endpoint following - the pattern in `create_payment_intent::tests::test_create_payment_intent_card_error`. - -- **M-3 — `NexusTool::timeout()` not overridden** - - What: Every endpoint uses the toolkit default of 10 s. Stripe - typical p95 latency is ~500 ms but the 99th percentile during - incidents has been seen above 5 s. The default is fine, but - leaving it implicit creates risk on any subsequent endpoint - addition that needs longer (e.g. webhook-based flows). - - Fix: Explicitly override per endpoint, e.g. - - ```rust - fn timeout() -> Duration { Duration::from_secs(15) } - ``` - - on every `impl NexusTool`. Document the chosen value next to the - override. - -### LOW - -- **L-1 — Doc comments missing on some fields** - - `get_payment_intent.rs::Input`, `confirm_payment_intent.rs::Input`, - `create_customer.rs::Input`, `get_balance.rs::Input`, - `list_charges.rs::Input` — `api_key` and other fields lack doc - comments. They surface in `input_schema` (the comments do, when - present); DAG authors and tooling rely on these. - - `create_payment_intent.rs::Input` is the gold standard — propagate - similar docstrings to the other five endpoints. - -- **L-2 — `Output::Ok` field types lack doc comments** - - Same surface (`output_schema`); add `///` comments to each port - field in every endpoint's `Output::Ok` variant. - -### INFO - -- **I-1 — `cargo audit` and `cargo deny` not run in this sandbox.** - The dev box does not have either binary installed. Both must be in - the CI image before the GitHub Actions workflow runs against - production. RustSec advisory database changes daily; baking the - checks into CI is the only durable mitigation. - -- **I-2 — One `expect()` in `StripeClient::new()`** - (`src/stripe_client.rs:34`) - - `Client::builder().user_agent(...).build().expect(...)`. This is - startup-time and the failure mode (`reqwest` cannot create a TLS - backend) is unrecoverable. Acceptable as a fail-fast. If we want - the agent to flag zero `unwrap()`s in any non-test code, this - becomes a tiny `match ... else std::process::abort()` cleanup — - not worth it. - -## Backtest results - -The crate has 17 mockito-backed unit tests covering happy paths and -the error classes enumerated below. No golden-file fixtures yet — those -will accumulate as the team probes against real Stripe test mode. - -| Endpoint | Happy path | Error coverage | -|---|---|---| -| create-payment-intent | ✅ | card_error, idempotency_error, rate_limit, zero-amount validation, empty-currency validation | -| get-payment-intent | ✅ | invalid_request (404), empty-id validation | -| confirm-payment-intent | ✅ (2 — succeeded + requires_action) | empty-id validation only | -| create-customer | ✅ | auth_error | -| get-balance | ✅ | _none_ | -| list-charges | ✅ | limit out-of-range only (local) | - -Action items in **M-2**. - -## Fuzz results - -Not executed for this audit — the in-sandbox build is debug and the -release binary was not started by the time of writing. The verify.sh -smoke loop covers /health and /meta only. Recommend running an explicit -malformed-payload sweep before mainnet: - -```sh -# template — adjust path / port -for payload in '{}' '{"api_key":null}' '{"api_key":1}' '{"api_key":"x","unknown":1}' \ - '{"api_key":"x","amount":-1,"currency":"usd"}' \ - '{"api_key":"x","amount":2000,"currency":""}'; do - curl -fsS -X POST 127.0.0.1:8080/create-payment-intent/invoke \ - -H 'content-type: application/json' --data-raw "$payload" || echo "failed: $payload" -done -``` - -Expected: never a 5xx, always either `Output::Err` JSON or a structured -4xx from the toolkit. - -## Conformance checklist (verbatim against `security-checklist.md`) - -### CRITICAL — all PASS - -- [x] C1 `Output` is enum with snake_case rename — every endpoint -- [x] C2 No `unwrap`/`expect`/`panic!` reachable from `invoke()` (the one `expect` at `stripe_client.rs:34` is `new()` only — see I-2) -- [x] C3 No `danger_accept_invalid_certs` / TLS bypass -- [x] C4 No hardcoded API keys (tests use `sk_test_FAKE...`) -- [x] C5 `Output::Err::reason` does not leak request URLs / file paths / stacks (partial — see M-1 for upstream-body echo concern) -- [x] C6 No `process::exit`, `unsafe`, child processes -- [x] C7 No env-var or disk reads for credentials -- [x] C8 `Input` does NOT derive `Debug` on any endpoint -- [x] C9 Tool is stateless — no `static`, `lazy_static`, `OnceLock`, `Mutex`, `RwLock`, `Cell`, `RefCell`. The `Arc` is a stateless connection pool; `.clone().with_auth()` produces a per-call builder. -- [x] C10 Cloud Run YAML mounts only `nexus-toolkit-config-*` and `nexus-allowed-leaders-*` -- [x] C11 README uses `sk_test_...`/`sk_live_...` only as placeholders, never as real keys - -### HIGH — all PASS - -- [x] H1 `#[serde(deny_unknown_fields)]` on every `Input` -- [x] H2 Six unique `path()` values -- [ ] H3 `timeout()` NOT explicitly overridden — using toolkit default of 10s. Acceptable for current endpoints; see M-3. -- [x] H4 User-agent set: `nexus-sdk-payments-stripe/1.0` -- [ ] H5 `cargo audit` not run in sandbox — see I-1 -- [x] H6 No logging of secret-shaped fields anywhere -- [x] H7 Credential field named `api_key` everywhere -- [x] H8 `idempotency_key: Option` on every POST endpoint; absent on GETs (correct) -- [x] H9 Tests use only `sk_test_FAKE` / `sk_test_FAKE_FOR_TESTS_ONLY` / `sk_test_BAD` -- [x] H10 Error classes covered in `from_api_error_type`: `invalid_request_error`, `card_error`, `validation_error`, `idempotency_error`, `rate_limit_error`, `authentication_error`, `api_error`, `api_connection_error` - -### MEDIUM - -- [x] M1 `description()` overridden on every endpoint -- [x] M2 Happy-path test on every endpoint -- [ ] M3 Error-variant test gaps in confirm-payment-intent, get-balance, list-charges -- [x] M4 Crucial Ok fields non-optional; `Option` only where Stripe genuinely omits the field -- [x] M5 No `println!` / `dbg!` outside `#[cfg(test)]` -- [x] M6 All deps use `workspace = true` - -### LOW - -- [ ] L1 Doc comments missing on five endpoints' Input fields -- [x] L2 `Cargo.toml description` set -- [x] L3 Six sections in README, one per FQN - -### Cross-cutting - -- [x] X1 All FQNs are `@1` (new tool) -- [x] X2 Descriptions accurately summarize the endpoint -- [x] X3 Idempotent under retry — reads are GET; writes carry `idempotency_key` -- [x] X4 Auth happens at signed-HTTP / `authorize()` layer, not in the tool's `Input` - -## Sign-off - -**Recommendation: ready-for-testnet.** - -**Blockers for mainnet:** - -- M-1 (raw body in fallback `reason`) -- M-2 (thin error-variant tests on three endpoints) -- M-3 (explicit `timeout()` override per endpoint) -- I-1 (wire `cargo audit` + `cargo deny` into CI) - -Once those four are closed and a `payments-stripe` testnet deploy has -been live without incident for at least 72 hours, mainnet promotion is -recommended. diff --git a/tools/payments-stripe/deploy/Dockerfile b/tools/payments-stripe/deploy/Dockerfile deleted file mode 100644 index 622fdd7..0000000 --- a/tools/payments-stripe/deploy/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -# ---- Builder ---------------------------------------------------------------- -FROM rust:1.83-bookworm AS builder -WORKDIR /build - -RUN apt-get update && apt-get install -y --no-install-recommends \ - pkg-config libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/* - -# Copy the whole workspace — `members = ["tools/*"]` requires every -# sibling crate to exist for cargo's resolver. -COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ -COPY tools tools -COPY helpers helpers - -RUN cargo +stable build --release --package payments-stripe - -# ---- Runtime ---------------------------------------------------------------- -FROM gcr.io/distroless/cc-debian12:nonroot AS runtime -WORKDIR /app - -COPY --from=builder /build/target/release/payments-stripe /app/payments-stripe - -ENV BIND_ADDR=0.0.0.0:8080 -EXPOSE 8080 - -USER nonroot -ENTRYPOINT ["/app/payments-stripe"] diff --git a/tools/payments-stripe/deploy/cloud-run.mainnet.yaml b/tools/payments-stripe/deploy/cloud-run.mainnet.yaml deleted file mode 100644 index 7970b80..0000000 --- a/tools/payments-stripe/deploy/cloud-run.mainnet.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# Credential model: this service holds NO Stripe API credentials. -# The only secrets mounted here are: -# - nexus-toolkit-config-mainnet-payments-stripe (Tool's own Ed25519 signing key) -# - nexus-allowed-leaders-mainnet (Leader public keys, not secret) -# Stripe `api_key` arrives in the per-request Input struct, sourced by -# the Leader at DAG-execution time. Adding any other secretKeyRef fails -# the auditor's C10 check. -apiVersion: serving.knative.dev/v1 -kind: Service -metadata: - name: "payments-stripe-mainnet" - labels: - network: mainnet - tool-fqn: "xyz.taluslabs.payments.stripe" - annotations: - run.googleapis.com/launch-stage: GA -spec: - template: - metadata: - annotations: - autoscaling.knative.dev/minScale: "1" - autoscaling.knative.dev/maxScale: "20" - run.googleapis.com/execution-environment: gen2 - spec: - serviceAccountName: "nexus-tools-mainnet@${PROJECT_ID}.iam.gserviceaccount.com" - timeoutSeconds: 30 - containerConcurrency: 80 - containers: - - name: tool - image: "${REGISTRY}/payments-stripe:${SHA}" - ports: - - name: http1 - containerPort: 8080 - env: - - name: BIND_ADDR - value: 0.0.0.0:8080 - - name: NEXUS_TOOLKIT_CONFIG_PATH - value: /etc/nexus/toolkit.json - - name: NEXUS_NETWORK - value: mainnet - volumeMounts: - - name: nexus-config - mountPath: /etc/nexus - readOnly: true - - name: allowed-leaders - mountPath: /etc/nexus/allowed_leaders - readOnly: true - resources: - limits: - cpu: "2" - memory: 1Gi - volumes: - - name: nexus-config - secret: - secretName: "nexus-toolkit-config-mainnet-payments-stripe" - - name: allowed-leaders - secret: - secretName: "nexus-allowed-leaders-mainnet" - traffic: - - percent: 100 - latestRevision: true diff --git a/tools/payments-stripe/deploy/cloud-run.testnet.yaml b/tools/payments-stripe/deploy/cloud-run.testnet.yaml deleted file mode 100644 index 59c22b0..0000000 --- a/tools/payments-stripe/deploy/cloud-run.testnet.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# Credential model: this service holds NO Stripe API credentials. -# The only secrets mounted here are: -# - nexus-toolkit-config-testnet-payments-stripe (Tool's own Ed25519 signing key) -# - nexus-allowed-leaders-testnet (Leader public keys, not secret) -# Stripe `api_key` arrives in the per-request Input struct, sourced by -# the Leader at DAG-execution time. Adding any other secretKeyRef fails -# the auditor's C10 check. -apiVersion: serving.knative.dev/v1 -kind: Service -metadata: - name: "payments-stripe-testnet" - labels: - network: testnet - tool-fqn: "xyz.taluslabs.payments.stripe" - annotations: - run.googleapis.com/launch-stage: GA -spec: - template: - metadata: - annotations: - autoscaling.knative.dev/minScale: "0" - autoscaling.knative.dev/maxScale: "5" - run.googleapis.com/execution-environment: gen2 - spec: - serviceAccountName: "nexus-tools-testnet@${PROJECT_ID}.iam.gserviceaccount.com" - timeoutSeconds: 30 - containerConcurrency: 80 - containers: - - name: tool - image: "${REGISTRY}/payments-stripe:${SHA}" - ports: - - name: http1 - containerPort: 8080 - env: - - name: BIND_ADDR - value: 0.0.0.0:8080 - - name: NEXUS_TOOLKIT_CONFIG_PATH - value: /etc/nexus/toolkit.json - - name: NEXUS_NETWORK - value: testnet - volumeMounts: - - name: nexus-config - mountPath: /etc/nexus - readOnly: true - - name: allowed-leaders - mountPath: /etc/nexus/allowed_leaders - readOnly: true - resources: - limits: - cpu: "1" - memory: 512Mi - volumes: - - name: nexus-config - secret: - secretName: "nexus-toolkit-config-testnet-payments-stripe" - - name: allowed-leaders - secret: - secretName: "nexus-allowed-leaders-testnet" - traffic: - - percent: 100 - latestRevision: true diff --git a/tools/payments-stripe/deploy/register.sh b/tools/payments-stripe/deploy/register.sh deleted file mode 100755 index 65bfed5..0000000 --- a/tools/payments-stripe/deploy/register.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# Idempotent registration of payments-stripe tools with Nexus. -# -# Usage: -# register.sh -# -# Reads tool paths from ../paths.json, fetches each tool's FQN from -# /meta, then registers (or updates) each FQN against the chosen Nexus -# network. - -set -euo pipefail - -URL="${1:?missing first arg: tool URL}" -NETWORK="${2:?missing second arg: testnet|mainnet}" - -case "$NETWORK" in - testnet|mainnet) ;; - *) echo "network must be 'testnet' or 'mainnet'"; exit 2 ;; -esac - -PATHS_FILE="$(dirname "$0")/../paths.json" -if [[ ! -f "$PATHS_FILE" ]]; then - echo "expected $PATHS_FILE to enumerate tool paths" >&2 - exit 2 -fi - -mapfile -t PATHS < <(jq -r '.[]' "$PATHS_FILE") - -for path in "${PATHS[@]}"; do - meta_url="${URL%/}${path}/meta" - meta="$(curl -fsS "$meta_url")" - fqn="$(echo "$meta" | jq -r .fqn)" - description="$(echo "$meta" | jq -r .description)" - - echo "registering $fqn at $URL$path on $NETWORK" - - if nexus tool list --network "$NETWORK" 2>/dev/null | grep -q "$fqn"; then - nexus tool update offchain \ - --network "$NETWORK" \ - --tool-fqn "$fqn" \ - --url "${URL%/}${path}" - else - nexus tool register offchain \ - --network "$NETWORK" \ - --tool-fqn "$fqn" \ - --url "${URL%/}${path}" \ - --description "$description" - fi -done diff --git a/tools/payments-stripe/paths.json b/tools/payments-stripe/paths.json deleted file mode 100644 index 28e0a40..0000000 --- a/tools/payments-stripe/paths.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - "/create-payment-intent", - "/get-payment-intent", - "/confirm-payment-intent", - "/create-customer", - "/get-balance", - "/list-charges" -] diff --git a/tools/payments-stripe/src/main.rs b/tools/payments-stripe/src/main.rs deleted file mode 100644 index d14ecf7..0000000 --- a/tools/payments-stripe/src/main.rs +++ /dev/null @@ -1,20 +0,0 @@ -#![doc = include_str!("../README.md")] -#![allow(clippy::large_enum_variant)] - -use nexus_toolkit::bootstrap; - -mod error; -mod stripe_client; -mod tools; - -#[tokio::main] -async fn main() { - bootstrap!([ - tools::create_payment_intent::CreatePaymentIntent, - tools::get_payment_intent::GetPaymentIntent, - tools::confirm_payment_intent::ConfirmPaymentIntent, - tools::create_customer::CreateCustomer, - tools::get_balance::GetBalance, - tools::list_charges::ListCharges, - ]); -} diff --git a/tools/payments-stripe/src/stripe_client.rs b/tools/payments-stripe/src/stripe_client.rs deleted file mode 100644 index 16af9af..0000000 --- a/tools/payments-stripe/src/stripe_client.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Stateless Stripe HTTP client. -//! -//! Credential model (audit checks C7, C9, C10): -//! - This client holds NO Stripe credentials between requests. -//! - The `api_key` is attached per-call via `.with_auth(&input.api_key)`. -//! - The struct's only persistent field is an `Arc` — -//! a stateless connection pool. `new()` is cheap and idempotent. -//! - No `std::env::var` reads; no on-disk reads. - -use { - crate::{ - error::{try_parse_api_error, StripeErrorKind, StripeErrorResponse}, - tools::STRIPE_API_BASE, - }, - reqwest::{Client, RequestBuilder}, - serde::{de::DeserializeOwned, Serialize}, - std::sync::Arc, -}; - -#[derive(Clone)] -pub struct StripeClient { - client: Arc, - base_url: String, - bearer: Option, - idempotency_key: Option, -} - -impl StripeClient { - pub fn new(base_url: Option<&str>) -> Self { - let base_url = base_url.unwrap_or(STRIPE_API_BASE).to_string(); - let client = Client::builder() - .user_agent("nexus-sdk-payments-stripe/1.0") - .build() - .expect("Failed to create HTTP client"); - Self { - client: Arc::new(client), - base_url, - bearer: None, - idempotency_key: None, - } - } - - /// Attach the per-request Stripe secret key. Returns a new builder - /// so the caller's base client never sees the credential. - #[must_use] - pub fn with_auth(mut self, bearer: &str) -> Self { - self.bearer = Some(bearer.to_string()); - self - } - - /// Attach an `Idempotency-Key` header. - #[must_use] - pub fn with_idempotency(mut self, key: &str) -> Self { - self.idempotency_key = Some(key.to_string()); - self - } - - pub async fn get(&self, endpoint: &str) -> Result - where - T: DeserializeOwned, - { - let req = self.apply_headers(self.client.get(self.url(endpoint))); - self.send(req).await - } - - /// Stripe uses `application/x-www-form-urlencoded` for write bodies, - /// not JSON. Pass a `serde_urlencoded`-compatible value. - pub async fn post_form(&self, endpoint: &str, body: &B) -> Result - where - T: DeserializeOwned, - B: Serialize + ?Sized, - { - let req = self.apply_headers(self.client.post(self.url(endpoint)).form(body)); - self.send(req).await - } - - fn url(&self, endpoint: &str) -> String { - format!( - "{}/{}", - self.base_url.trim_end_matches('/'), - endpoint.trim_start_matches('/') - ) - } - - fn apply_headers(&self, mut req: RequestBuilder) -> RequestBuilder { - if let Some(ref bearer) = self.bearer { - req = req.bearer_auth(bearer); - } - if let Some(ref key) = self.idempotency_key { - req = req.header("Idempotency-Key", key); - } - req - } - - async fn send(&self, req: RequestBuilder) -> Result - where - T: DeserializeOwned, - { - let response = match req.send().await { - Ok(r) => r, - Err(e) => { - return Err(StripeErrorResponse { - reason: format!("Network error: {}", e), - kind: StripeErrorKind::from_network_error(&e), - status_code: Some(0), - }); - } - }; - - let status = response.status(); - let text = match response.text().await { - Ok(t) => t, - Err(e) => { - return Err(StripeErrorResponse { - reason: format!("Failed to read response: {}", e), - kind: StripeErrorKind::Parse, - status_code: None, - }); - } - }; - - if !status.is_success() { - if let Some(parsed) = try_parse_api_error(&text, status.as_u16()) { - return Err(parsed); - } - return Err(StripeErrorResponse { - reason: format!("Stripe API error ({}): {}", status, truncate(&text, 512)), - kind: StripeErrorKind::from_status_code(status.as_u16()), - status_code: Some(status.as_u16()), - }); - } - - serde_json::from_str::(&text).map_err(|e| StripeErrorResponse { - reason: format!("Failed to parse JSON: {}", e), - kind: StripeErrorKind::Parse, - status_code: None, - }) - } -} - -fn truncate(s: &str, max: usize) -> String { - if s.len() <= max { - s.to_string() - } else { - let mut end = max; - while !s.is_char_boundary(end) { - end -= 1; - } - format!("{}…", &s[..end]) - } -}