9.3 KiB
Plan: agent-delegable payment-provider connect (without making it a fund-redirection key)
Status: APPROVED — agreed shape, ready to build. No urgency (doc-harness Path 1, no
payments, ships first; this work feeds Path 2, the full regtest buyer-pays walkthrough).
Author: this session, 2026-06-16. Sign-off: other dev, 2026-06-16.
Related: merchant-onboard role shipped in 0.2.0:57 (src/api/api_keys.rs); multi-provider
model (plans/multi-provider-payment-model.md).
1. Problem / motivation
Make Keysat fully agent-compatible: an operator hands an agent a scoped credential and says "add Keysat licensing to this software and connect a Bitcoin wallet on BTCPay," and the agent does the whole thing over the API. Two use cases:
- Production delegation — a real operator delegates first-time setup (catalog + connect their BTCPay) to an agent.
- Doc-onboarding test harness — a fresh AI agent integrates Keysat from the docs alone against a disposable server, published as marketing ("all the agent had to do was X, Y, Z"). For dev/testing the agent uses regtest (private Bitcoin network — mine blocks on demand, no faucets, no real money).
merchant-onboard (shipped :57) covers catalog + manual license issuance end-to-end but
cannot connect a payment provider — that stays master-key only. So the agent story stops
one step short of "accept buyer payments."
2. The core risk (why connect is the crown jewel) — ratified
Creating products/policies/licenses is "what you sell." Connecting — or re-connecting —
a payment provider is "where the money goes": a credential that can do it can repoint
settlement at an attacker-controlled wallet/store, after which every buyer payment lands
there. Direct theft-of-funds, strictly worse than any catalog write. The value of a
scoped key is bounded blast radius; folding connect into merchant-onboard would make it
"near-total financial control with a friendly name."
Binding decision (both devs): do NOT fold connect into merchant-onboard. A key that can
repoint settlement is a fund-redirection key; bounding that blast radius is the whole point.
3. Current state (verified against code, 2026-06-16)
- All provider-mutating endpoints are master-key only (
require_admin):- BTCPay connect —
api/btcpay_authorize.rs:87(OAuth-style: returns anauthorize_url; a human approves on BTCPay, then the callback persists store id / API key / webhook secret). - BTCPay disconnect —
btcpay_authorize.rs:426. - Zaprite connect —
api/zaprite_authorize.rs:56(API-key paste; no OAuth step). - Zaprite disconnect —
zaprite_authorize.rs:207.
- BTCPay connect —
- Only
payment_providers:readexists as a scope; there is nopayment_providers:writetoday.merchant-onboardgrants all:read+products:write+policies:write+licenses:write. Master-only ops sit behindrequire_admin, never consultingRole::grants. - Asymmetry: BTCPay is self-hosted, can run regtest/testnet (the safe case). Zaprite is a hosted mainnet SaaS — no regtest equivalent assumed, so scoped Zaprite-connect stays master-only and the demo uses BTCPay (the motivating case anyway).
4. Options (B rejected; built shape is C + D, gated by E)
| # | Option | Verdict |
|---|---|---|
| A | Keep connect master-only (status quo) | Safe, not fully agent-compatible |
| B | Fold connect into merchant-onboard |
Rejected (binding) — fund-redirection key |
| C | À-la-carte scope payment_providers:write, never in any default role |
Chosen packaging |
| D | Network gate: scoped connect only regtest/testnet/signet; mainnet → master | Chosen (inner defense) |
| E | Sandbox-mode daemon flag for disposable instances | Chosen as the OUTER gate (see §5) |
5. Agreed design — gate order matters
A scoped payment_providers:write connect is permitted iff ALL hold, checked in this
order:
- OUTER — sandbox-mode daemon flag is ON. On a production (non-sandbox) daemon, scoped payment-connect is disabled entirely — even regtest. (Refinement, dev: a scoped key connecting a regtest provider on a production box would knock out the live store's real payments — denial-of-revenue, not theft, but still bad. So the flag, not the network, is the first gate.)
- INNER — target network is non-mainnet (regtest/testnet/signet). Defense-in-depth so that even on a sandbox daemon a scoped key can't wire up mainnet. Fail closed: if the network can't be positively determined as non-mainnet, treat it as mainnet → deny.
- BTCPay OAuth human-approve still happens (someone approves on BTCPay's side).
Master key bypasses 1–2 and may connect any network (still subject to BTCPay's own OAuth).
The sandbox flag is daemon-level config (env / launch), read at boot — NEVER settable via any API, scoped or otherwise. (Refinement, dev: otherwise a scoped key flips sandbox on, then connects. Keep it strictly out-of-band.)
Net: full agent-compatibility for dev/testing on a sandbox+regtest instance (zero master-key steps — the doc-harness Path 2), and production stays locked: no scoped key can connect, reconnect, or disconnect a provider on a live box.
6. Resolved decisions (the dev's open-question answers)
- BTCPay network detection — VERIFIED. Greenfield's
GET /api/v1/server/info(ServerInfoData) has no network/chainType field (onlysyncStatus[].chainHeightetc.). Determine the network from a network-encoding artifact, not a field:- Primary: fetch a store on-chain receive address —
GET /api/v1/stores/{storeId}/payment-methods/{pmid}/wallet/address→OnChainWalletAddressData.address— and classify by prefix: bech32 HRPbc1…=mainnet,tb1…=testnet/signet,bcrt1…=regtest (legacy base581/3=mainnet vsm/n/2= test/regtest). - Secondary: the on-chain payment method's
derivationScheme(NBXplorer format) — xpub/ ypub/zpub=mainnet, tpub/upub/vpub=testnet. (Fetching config needsbtcpay.store.canmodifystoresettings.) - Fail closed: Lightning-only store / no address / unrecognized prefix → treat as mainnet → require master.
- Primary: fetch a store on-chain receive address —
- Zaprite — no non-mainnet mode assumed → scoped Zaprite-connect stays master-only; demo uses BTCPay. (Agreed.)
- Reconnect / rotate of an already-connected mainnet provider — always master-only. (Strong yes — that's the exact attack.) Sandbox/regtest reconnect follows the §5 scoped rules; mainnet reconnect is never scoped.
- Packaging — à-la-carte
payment_providers:write(composes withmerchant-onboard, avoids role sprawl), not a new fixed role. Implementation note: "composes with" means moving from one-role-per-key to role + optional extra scopes per key (or full per-key scope sets) — a schema addition (scoped_api_keysgains a scopes column, migration0024). Acceptable given no urgency; flagged so it's not a surprise at build time.
7. Design sketch (build)
- Scope string: add
"payment_providers:write". Granted per-key (à-la-carte, §6.4), never via a:writesuffix match, never inmerchant-onboard's grant set. - Sandbox flag: e.g.
KEYSAT_SANDBOX_MODE=1, read at boot intoAppState; surfaced read-only in/v1/admin/tier-style status + a prominent "SANDBOX" banner in the admin UI so a sandbox instance is never mistaken for production. No setter endpoint of any kind. - Gate helper: replace
require_adminon the connect handlers withrequire_provider_connect(state, headers, target_network):- master → allow any network;
- scoped w/
payment_providers:write→ allow iffstate.sandbox_modeandtarget_network != mainnet; - else 403.
Resolve
target_network(§6.1) before persisting anything.
- Reconnect/disconnect: mainnet reconnect always master-only (§6.3); disconnect on a production daemon stays master-only (denial-of-revenue, consistent with §5.1). In sandbox, both follow the scoped rule.
- Audit: every scoped connect writes an audit row with actor hash + resolved network + provider.
8. Migration / back-compat
- New scope string + gate helper + sandbox flag. Migration
0024only for §6.4 (per-key extra scopes column onscoped_api_keys). - Master-key automation unaffected; existing
merchant-onboardkeys unaffected (capability unchanged); existing keys default to no extra scopes.
9. Testing
- Scoped
payment_providers:writekey on a sandbox daemon: connect regtest BTCPay → allowed; connect mainnet BTCPay → 403; Lightning-only / unknown network → 403 (fail-closed). - Same key on a production (non-sandbox) daemon: connect regtest → 403 (outer gate); proves §5.1.
- Sandbox flag is not flippable via API (assert no endpoint mutates it).
- Master key: connect any network → allowed.
merchant-onboardwithout the extra scope → 403 on connect everywhere (proves no role widen).- Mainnet reconnect/rotate → master-only even in sandbox.
10. Status
Shape approved by both devs: C (à-la-carte payment_providers:write) + D (network gate,
fail-closed) gated by E (sandbox flag as the outer gate, daemon-level only), BTCPay OAuth
preserved, B explicitly rejected. Build when it suits — Path 1 ships first.