Closes the silent-loss hole in outbound webhook delivery. The worker
in src/webhooks.rs retries failed deliveries with exponential backoff
up to 10 attempts, then sets next_attempt_at = NULL and walks away.
Pre-this-commit, those "dead-lettered" rows sat in webhook_deliveries
forever with no surface for the operator to discover, inspect, or
recover from them — a subscriber that was down for >6h during a
license-issuance burst would silently lose those events forever.
What's new:
- repo::DeliveryStatusFilter — enum with parse() so query strings
map cleanly to SQL predicates.
- repo::list_deliveries — endpoint_id + status + limit, newest first.
- repo::requeue_delivery — resets attempt_count=0, clears delivered_at
and last_error, sets next_attempt_at=now. The worker picks it up on
the next 5s tick.
- src/api/webhook_deliveries.rs — admin module with two handlers:
- GET /v1/admin/webhook-deliveries?endpoint_id=…&status=…&limit=…
- POST /v1/admin/webhook-deliveries/:id/retry (audit-logged as
webhook_delivery.retry; 404 on missing id)
- Routes registered in src/api/mod.rs alongside the existing
webhook_endpoints CRUD.
- tests/api.rs gains webhook_dlq_lists_failed_and_retry_requeues:
seeds three deliveries directly via SQL (one each: delivered,
pending, dead-lettered), exercises the list filter, runs the retry,
asserts the row migrates from failed→pending, audit row is written,
404 on bad id, 400 on bad status filter.
Worker code is unchanged. The DLQ is operator-actionable infrastructure
on top of the existing retry semantics.
Test count: 23 (9 unit + 4 migration + 10 API), up from 22.
Drops the legacy compat path. `purchase::start` now calls
`state.payment_provider().await?.create_invoice(CreateInvoiceParams {
...})` instead of `state.btcpay_client().await?.create_invoice(...)`.
Provider-specific concerns (BTCPay's checkout-URL rewriting from the
internal Docker hostname to the public domain, metadata enrichment
with `orderId` / `source`) move inside the BtcpayProvider impl where
they belong; the same code path now serves any future provider
(Zaprite, etc.) without fork/copy.
URL rewriting is removed from the caller (no longer needs to know
which provider's URLs to rewrite or how). The
`crate::payment::btcpay::rewrite_to_public` function stays on the
provider impl; pubpath unchanged.
Adds `paid_purchase_creates_invoice_via_provider` integration test —
previously deferred per :42's release notes because the compat path
prevented MockPaymentProvider from substituting. Now the mock works
through the same call site as production. Verifies:
- daemon delegates invoice creation to the provider
- returned provider_invoice_id is stamped on the local invoice row
- checkout_url is what the provider returned
- no license issued at this stage (that's the webhook's job)
Test count: 22 (9 unit + 4 migration + 9 API).
Closes the next-biggest test gap after migration tests. The daemon has
54+ HTTP endpoints, all previously untested at the request/response
level — same shape of blind spot that allowed the v0.1.0:39 migration
bug to ship.
What's new:
- src/lib.rs — exposes the daemon's modules as a library so integration
tests can import them (`pub mod api;`, etc.). Module source files are
unchanged; main.rs now imports via `use keysat::...` instead of
declaring `mod api;` directly. No runtime behaviour change in the
binary.
- tests/api.rs — 5 integration tests that drive real HTTP requests
through axum::Router::oneshot against a real SQLite tempfile pool
(same options as src/db/mod.rs::init):
1. health_endpoint_returns_200 — framework smoke test
2. admin_endpoint_rejects_missing_or_wrong_auth — 401 vs 403 paths
3. admin_creates_product_with_correct_token — full happy path
(auth → handler → DB insert → audit log → response)
4. validate_rejects_unsigned_garbage — early parse-fail surfaces
as `ok: false, reason: "bad_format"` (HTTP still 200)
5. validate_accepts_well_formed_license — issues a license via
repo, signs a matching LicensePayload with the daemon's
actual key, encodes to wire format, validates via the
endpoint, asserts ok=true plus populated metadata fields
Test count: 9 unit + 4 migrations + 5 API = 18 (was 13).
Cargo.toml dev-deps gain `tower = { version = "0.4", features = ["util"] }`
for ServiceExt::oneshot. The main `tower` dep is feature-minimal because
axum only needs a subset.
Out of scope (explicit follow-ups):
- Purchase happy path (needs a MockPaymentProvider implementing the
trait; ~250 LOC of mock + ~200 LOC of test).
- Webhook handler with idempotency assertions (same MockPaymentProvider
dependency).
- Tier-cap enforcement (mechanically simple; small follow-up PR).
- Discount-code atomic reserve race (better as a SQL-layer unit test
than an HTTP integration test).
- Rate-limiting (interacts with shared state; needs careful isolation).
- Cookie/session auth (already covered in session_layer.rs).