Commit Graph

4 Commits

Author SHA1 Message Date
Grant 3afac078d4 Add sandbox flag + per-key à-la-carte scopes (payment-connect foundation)
Foundation for agent-delegable payment-provider connect
(plans/agent-payment-connect-scope.md, slices 1-2 of 5). Not yet wired to any
connect endpoint — the gate (require_provider_connect + BTCPay non-mainnet
network check) is a follow-up.

- Config.sandbox_mode from KEYSAT_SANDBOX_MODE (daemon-level, never settable
  via any API); surfaced read-only in /v1/admin/tier as "sandbox".
- Migration 0024: additive scoped_api_keys.extra_scopes column (JSON array).
- Per-key à-la-carte scopes: require_scope grants via role OR a key's
  extra_scopes; GRANTABLE_EXTRA_SCOPES allowlist (payment_providers:write
  only), validated on create and echoed in create/list responses.
- payment_providers:write is in NO role: grants() carves the à-la-carte set
  out of full-admin's wildcard, so even a scoped full-admin key can't reach
  it through its role — only a per-key grant does. extra_scopes parsing
  fails closed (NULL/malformed -> no grant).
- Tests: invariant (no role grants the à-la-carte set), fail-closed parsing,
  create/list round-trip, reject ungrantable scope. Suite green: lib 13, api 59.
2026-06-16 21:16:20 -05:00
Grant 783372c03b Confirm settle with provider API before issuing; add test-injection seam
The settle-webhook honored payment on the webhook body's claim alone.
Zaprite webhooks carry no signature, so a forged order.change/status=PAID
POST with a buyer-visible order id minted a signed license without payment.

handle_inner now re-fetches provider.get_invoice_status and requires Settled
before persisting "settled" or taking any settle-derived action (issuance,
tier-change, subscription renewal — the guard precedes all of them). On a
provider-API error it acks 200 without issuing, so a transient outage can't
trigger a webhook retry storm; the reconcile loop re-confirms and issues later.

Adds the always-compiled AppState::provider_override seam (None in prod),
honored by provider_from_row at every resolution site, so integration tests
drive the real resolver with a MockPaymentProvider. Greens the two
paid_purchase_* tests, deletes the dead payment_provider_preference_round_trip,
and adds forged-settle + provider-unreachable regression tests. api 47/47.

Not addressed: a literal paid-amount/currency check (needs a trait change).
2026-06-12 22:36:42 -05:00
Grant eb885502ba Multi-currency Phase 4 — rate fetcher with Kraken/Coinbase/CoinGecko fallback
src/rates.rs adds an in-memory rate cache (60s TTL) with a 3-source
fallback chain. AppState gains `rates: Arc<RateCache>`. Manual pins
via the settings table override the chain — used by tests for
deterministic conversions and by operators during maintenance
windows.

Admin endpoints:
- GET /v1/admin/rates: cache snapshot
- POST /v1/admin/rates/refresh: force re-fetch (audit-logged)

Two new tests (network-free, manual-pin path):
- rate_cache_honors_manual_pin_from_settings
- admin_rates_endpoint_reflects_manual_pin

Test count: 36 (was 34).
2026-05-08 12:16:22 -05:00
Grant 5ec9a6e8c0 Migrate reconcile + tipping onto PaymentProvider trait; add worker tests
Two compat-path holdovers migrated:

- src/reconcile.rs: was state.btcpay_client().get_invoice() with
  manual JSON parsing of BTCPay-specific status strings ("Settled",
  "Complete", "Expired", "Invalid"). Now state.payment_provider()
  .get_invoice_status() returning the typed ProviderInvoiceStatus
  enum. The string normalization moves into BtcpayProvider's impl
  where it belongs.

- src/tipping.rs: was state.btcpay_client().pay_lightning_invoice()
  returning raw JSON, then manual paymentHash extraction. Now
  provider.pay_lightning_invoice() returning a typed PaymentReceipt
  { payment_hash, raw }. The audit message now records the active
  provider's kind() rather than hardcoding "BTCPay LN node".

Combined with v0.1.0:43's purchase migration, the daemon's
non-test code now contains zero calls to state.btcpay_client() or
.btcpay_webhook_secret(). Those compat accessors stay on AppState
for v0.2 (no need to break things gratuitously) but they're dead
code in the production path. Zaprite's drop-in only needs to
implement the trait.

Worker integration tests (tests/worker.rs):

- worker_marks_failure_and_schedules_retry_on_500: spins up a tiny
  axum receiver that 500s, calls webhooks::tick(), verifies attempt
  count and next-attempt scheduling.
- worker_dead_letters_after_max_attempts: seeds a row at attempt
  count 9, ticks once, verifies attempt_count → 10 and
  next_attempt_at → NULL. Confirms the row also satisfies the admin
  DLQ predicate (the contract :43's webhook_deliveries.rs depends
  on).
- worker_marks_success_on_2xx: pins the happy path.

webhooks::tick is now `pub` so integration tests can drive it
synchronously.

Test count: 26 (9 unit + 4 migration + 10 API + 3 worker).
2026-05-08 10:40:11 -05:00