Commit Graph

27 Commits

Author SHA1 Message Date
Grant 8eb4a97c6f Gate scoped BTCPay connect to sandbox + non-mainnet
Slices 3-4 of agent-payment-connect: a scoped key carrying the a-la-carte
payment_providers:write scope may connect a BTCPay provider, but only on a
sandbox daemon (KEYSAT_SANDBOX_MODE) and only for a non-mainnet
(regtest/testnet/signet) store. Master may connect any network; disconnect and
production/mainnet reconnect stay master-only. A credential that can repoint
settlement is a fund-redirection key, so the gate is deliberately narrow and
fails closed.

- require_provider_connect: outer gate (sandbox flag) at start_connect
- btcpay/network.rs classify_address_network + client::fetch_onchain_network:
  resolve the store network at finish_connect, fail-closed to mainnet on any
  ambiguity (no on-chain method, non-2xx, non-JSON, unknown prefix), before any
  webhook/persist side effect
- initiator carried across the OAuth round-trip via btcpay_authorize_state
  (migration 0025: scoped_initiator + initiator_actor_hash); scoped connects
  are audited
- the GET callback now returns the error's HTTP status (was a misleading 200 on
  a denied connect)
- openapi.rs documents the BTCPay connect/callback/status/disconnect paths and
  the key-creation scopes field

Validated end-to-end against a live regtest BTCPay. Full suite green; adds gate
+ network unit/integration tests.
2026-06-17 09:31:57 -05:00
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 d5885d1d97 Add merchant-onboard scoped-key role for self-serve onboarding
New scoped API-key role granting read + products:write + policies:write +
licenses:write — the least-privilege credential for end-to-end catalog
setup and license issuance (create product, define policies/tiers, issue
licenses against them) without holding the master key.

The catalog write scopes already existed and were enforced on the
endpoints; only the role->scope expansion was missing. So this is a new
Role variant, not a scope-model change. grants() matches scope strings
explicitly (never by :write suffix) so the role can't widen into
settings / payment / merchant-profile / webhook writes, and every
master-only operation stays behind require_admin and so is structurally
unreachable. Existing tier caps still bound it (Creator: 5 products /
5 policies per product).

Migration 0023 rebuilds scoped_api_keys to widen the role CHECK (SQLite
can't alter a CHECK in place); the table has no FKs, so it's a plain
copy/drop/rename. Test covers the full onboard chain under the key's own
credential plus denial of master-only gates and support-only writes.
2026-06-16 18:55:18 -05:00
Grant b088bfc062 Wire product→merchant-profile write path
Multi-profile resolution shipped in :52 but nothing wrote
products.merchant_profile_id, so it was non-functional end to end.

Add merchant_profile_id to the Product model + all four product
SELECTs, a set_product_merchant_profile writer (validates the target
profile exists, returning 404 instead of a raw FK-violation 500), and
thread an optional field through CreateProductReq (post-write) and
UpdateProductReq (double-Option; Some(None) clears to default). The
admin SPA product form shows a profile picker only when >1 profile
exists. Mirrors the entitlements-catalog post-write pattern.

Tests: repo round-trip (attach/resolve/clear/bad-id) + HTTP handler
arms. api suite 54→56, full suite green.
2026-06-15 21:38:24 -05:00
Keysat 0508690d5a Wire scoped API keys and add advisory settle-amount tripwire
Scoped API keys (P1): migrate 58 admin endpoints from require_admin to
require_scope so ks_ keys with Read-only/License-issuer/Support/Full-admin roles
work as intended. 12 sensitive endpoints stay master-key-only (issuer key,
provider connect/disconnect, web password, api-key CRUD, db-info, operator-name,
per-license tier change). require_scope is re-exported from api::admin so both
auth gates import from one place. Adds role-boundary tests.

Settle-amount tripwire (P1): get_invoice_status now returns
ProviderInvoiceSnapshot { status, amount }. On a confirmed settle,
audit_settle_amount (shared by the webhook and reconcile issue paths) compares
the provider-reported sat amount against the invoice's amount_sats and, on drift,
logs a warning + writes an invoice.amount_mismatch audit row, then issues anyway.
Advisory by design: a hard gate would fight an operator's BTCPay payment
tolerance, and Settled already implies paid-in-full. SAT-only — skips non-SAT
settles (fiat subscription renewals) and unparseable amounts.
2026-06-13 00:10:45 -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 31f4670efa Fix ambiguous-column bug in merchant-profile resolution
get_merchant_profile_for_product selected the bare MERCHANT_PROFILE_COLS list
while JOINing products (which also has an id), so SQLite raised "ambiguous
column name: id" on every execution. The function runs on every purchase, so
every paid purchase on 0.2.0:52 returned HTTP 500. Replace the JOIN with an
equivalent correlated subquery, keeping merchant_profiles the only table in
FROM; behavior on NULL/missing merchant_profile_id is unchanged (no row, caller
falls back to the default profile).

Also from the verification pass:
- Add merchant_profile_provider_resolution_queries_round_trip, exercising the
  previously untested runtime-prepared resolution / CRUD / preference queries.
- Repair three test call sites for the new create_invoice / create_subscription
  params; capture the response body in the paid_purchase status assertion.
- Align manifest license to LicenseRef-Keysat-1.0; drop an unused import.
2026-06-12 19:39:33 -05:00
Grant 519fa1a8e6 v0.2.0:14 — Entitlements catalog read fix + drag-and-drop tier ordering
Bug fix:
  Product entitlements catalog reads were silently dropping. Every
  SELECT against the products table was missing entitlements_catalog_json
  from the column list, so the PATCH handler wrote the catalog correctly
  but every subsequent read returned null. Admin UI edits appeared to
  vanish on save. Fix: added the column to all four product SELECTs
  in repo.rs (list_products, get_product_by_slug, get_product_by_id —
  one column list, replace_all). Added regression test
  product_entitlements_catalog_round_trips_through_list_endpoint that
  exercises the full PATCH → list round-trip the admin UI hits.

UX:
  Drag-and-drop reordering on the tier-card grid. Operator drags any
  tier card to a new position; on drop, parallel PATCH requests set
  tier_rank 1..N based on the new visual order. Archived tiers are
  excluded (their position in the ladder is moot). Edit-policy modal
  retains the tier_rank number field for the two cases drag-and-drop
  can't express (precise override + blank-to-remove-from-ladder).
  Cursor signals grab/grabbing on hover/drag; dragging card lifts +
  fades for visual feedback.

Copy:
  Policies-tab section headers now show just the product name
  ("Keysat") instead of redundant "Keysat — keysat". Entitlements-
  catalog row editor description placeholder shortened from
  "Description (shown on buy page tooltip)" to "Description (buyer
  tooltip)" so it fits the column; full hover hint kept on the
  input's title attribute.

Test count: 87.
2026-05-11 11:14:20 -05:00
Grant 76fe7fe6b9 v0.2.0:13 — CORS on public endpoints
Adds tower-http CorsLayer at the outermost router position so:

- Browsers can fetch /v1/products/<slug>/policies, /v1/openapi.json,
  /v1/issuer/public-key, /v1/validate from any origin. Unblocks the
  dynamic pricing page on docs.keysat.xyz reading live tier config
  from licensing.keysat.xyz.
- Preflight OPTIONS is handled by the CorsLayer directly, never
  reaches the session-bridge or any handler — so admin endpoints
  don't 401 on preflight.

Security posture unchanged. Access-Control-Allow-Credentials is OFF.
The combination of ACAO=* and no-credentials means a cross-origin
page can read public responses but can't ride a logged-in admin
session cookie to hit /v1/admin/*. Admin endpoints still require
an explicit Bearer token, which browsers don't auto-attach
cross-origin.

Tests: +2 CORS regression tests (cors_allows_cross_origin_on_public_
endpoints, cors_preflight_returns_2xx_without_auth). Full suite:
85 passing.
2026-05-11 10:17:15 -05:00
Grant 257669092b v0.2.0:11 + v0.2.0:12 — Archive, Settings, agent surface, machines redesign
Two release cycles prepared together: v0.2.0:11 (policy archive + safe-
delete cleanup + brand-consistent confirm modals) and v0.2.0:12 (Settings
tab + agent-friendly operator API + machines tab redesign + buyer-facing
copy alignment).

Highlights:

- Migration 0015: policies.archived_at column. Archive button on tier
  cards; safe-delete relaxed to ignore revoked-license tombstones;
  renewal worker refuses archived policies.
- Migration 0016: scoped_api_keys table. Four roles (read-only,
  license-issuer, support, full-admin) with bounded scopes. Master
  admin_api_key still works on every endpoint; scoped keys gated on
  endpoints wired through require_scope().
- New /v1/openapi.json — public, no auth. Curated OpenAPI 3.1 spec
  for agent / SDK discovery.
- New Settings tab: Operator name + Payment providers panel + API
  keys management. Replaces 8 StartOS Actions (Zaprite all, BTCPay
  all, operator name, switch-provider). StartOS Actions pruned to 4
  install-time essentials.
- Machines tab rewritten: global default view grouped by product,
  filter pills with counts, quick-stats row, drill-down via new
  "Machines" button on each Licenses-tab row. New repo helper
  list_machines_admin joins machines x licenses x products
  server-side.
- Branded confirmModal replaces every native window.confirm() call
  in the admin UI (7 callsites).
- Enforce mode killed: KEYSAT_LICENSE_ENFORCE compile-time flag
  retired; daemon always boots; missing self-license -> Creator
  (free) tier. "Unlicensed" label gone from admin UI.
- Zaprite gated on the new zaprite_payments entitlement (renamed
  from card_payments to reflect the broader gateway).
- Creator code cap 5 -> 10.
- KEYSAT_AGENT_GUIDE.md: auth, role-to-scope mapping, error envelope,
  webhook events, worked recipes.
- Buyer-facing copy aligned with new positioning: "Bitcoin-native
  self-hosted software licensing" everywhere on production surfaces.
- Cross-product safety section (Section 9a) added to KEYSAT_INTEGRATION.md.
- 5 new API integration smoke tests covering OpenAPI, scoped API
  keys CRUD, role-elevation guard, and Zaprite-tier gating.

Test count: 83 passing (was 78). All migration tests pass against
0015 and 0016 applied to populated DBs.
2026-05-11 08:45:25 -05:00
Grant c5d716a6d4 Tier upgrades Phase 4 — admin force-change + renewal-worker hook
Closes the operator side of TIER_UPGRADES_DESIGN.md. With this in,
operators can force-change any license to any policy under the same
product (sideways, cross-NULL-rank, perpetual downgrades all
allowed) — and scheduled tier changes (e.g. recurring downgrades
recorded with future effective_at) actually fire at cycle boundaries.

New endpoint:
- POST /v1/admin/licenses/:id/change-tier
  Body: { to_policy_slug, skip_payment: bool, reason?: string }

  skip_payment=true (comp upgrade / support fix-up): apply
  immediately, write a tier_changes row with proration=0 and
  invoice_id=NULL, fire the license.tier_changed webhook, audit-log
  with actor=admin_api_key.

  skip_payment=false: same as buyer's /v1/upgrade — create a
  provider invoice for the prorated charge, persist the local
  invoice + a tier_changes row tied to it, return the checkout URL.
  Operator forwards it to the buyer through whatever channel they
  use. Webhook applies on settle.

  Bypasses ladder rules entirely (sideways, perpetual downgrade,
  recurring → perpetual all OK). Same-product / different-policy /
  active-target checks still apply.

QuoteMode refactor (src/upgrades.rs):
- compute_upgrade_quote now takes QuoteMode::{Buyer, Admin}.
- Buyer mode = strict ladder rules (per Phase 2).
- Admin mode = bypass ladder + downgrade gates; infer direction
  from rank-diff if both ranked, else from price-diff.
- Buyer endpoint passes Buyer; admin endpoint passes Admin.

Renewal-worker hook (src/subscriptions.rs):
- Before pricing each renewal cycle, the worker calls
  apply_pending_tier_changes(state, sub). This finds tier_changes
  rows for the sub's license where effective_at <= now AND
  invoice_id IS NULL AND license.policy_id != to_policy_id (i.e.
  scheduled comp/admin changes that haven't been applied yet).
  Each pending change is applied via apply_tier_change (which
  also rewrites the sub's policy_id / listed_value / period_days).
  After applying, the worker re-fetches the sub and prices the
  next invoice at the NEW tier's listed_value.
- This is what makes recurring downgrades actually take effect at
  the cycle boundary (admin records "Pro → Standard at next
  renewal", the worker applies it, the new invoice bills at
  Standard's price).
- Idempotent: re-running the hook on a license already on the
  target tier finds zero pending rows (the policy_id != check
  filters them out).

Tests (+5, total now 77):
- admin_change_tier_skip_payment_applies_immediately — comp path
  flips license + writes tier_change row with no invoice
- admin_change_tier_allows_perpetual_downgrade — the case the
  buyer endpoint rejects with 400 "admin-only"
- admin_change_tier_rejects_zero_charge_paid_path — sideways
  attempt with skip_payment=false hints at switching to true
- admin_change_tier_requires_admin_token — 401 without auth
- renewal_worker_applies_pending_tier_change_before_billing —
  the headline behavior: a pending downgrade tier_change with
  effective_at=now causes the next renewal to bill at the new
  (lower) tier's price, NOT the old one. Uses a CapturingProvider
  mock that stashes the last sat amount it saw so the assertion
  is on what the worker actually billed.
2026-05-08 20:12:44 -05:00
Grant b7fa6c7dae Tier upgrades Phase 3 — buyer-facing HTTP endpoints
Closes the buyer self-service tier-upgrade loop. With this in,
SDKs can wire an "Upgrade to Pro" button inside the operator's
app and the daemon handles quote → invoice → settle → apply
without operator involvement.

New endpoints (auth via signed license_key in body, same model
as /v1/recover and /v1/subscriptions/cancel — no admin token,
no cookie):

- POST /v1/upgrade-quote   — read-only quote. "If I upgraded to
                             <tier>, what would I owe right now,
                             when do entitlements take effect,
                             what will the next renewal charge?"
- POST /v1/upgrade         — buyer commits. Daemon recomputes the
                             quote (don't trust client shaping),
                             rejects 0-charge upgrades (admin path
                             only), creates a provider invoice for
                             the prorated charge in the listed
                             currency converted to sats, persists
                             the local invoice + a tier_changes
                             row tying them together, returns the
                             checkout URL.

Webhook handler change (src/api/webhook.rs):
- On invoice settle, BEFORE the subscription / license-issuance
  branches, look up the invoice in tier_changes via
  upgrades::get_tier_change_by_invoice. If present, run the
  apply path: mutate the existing license's policy_id +
  entitlements + max_machines + grace + expires_at, mutate any
  tied subscription's policy_id + listed_value + period_days
  (so future renewals charge the new tier), audit, fire the new
  `license.tier_changed` webhook event, ack 200.
- Idempotent: re-delivered webhook on an already-applied
  tier change is a no-op (license.policy_id == target.id check).
- Critically: the existing license_id is preserved. Buyers
  keep the same signed key; on next online validation their
  app sees the new entitlements. No new license is issued.

Phase 3 scope deliberately excludes:
- Buyer-initiated DOWNGRADES. compute_upgrade_quote already
  returns 0-charge quotes for recurring downgrades (effective at
  next_renewal_at), but applying that at the cycle boundary
  needs renewal-worker integration. Phase 4 lands the admin
  endpoint AND the worker hook in one go. For v0.2.x the buyer
  endpoint rejects with 400 "admin-only".
- Admin force-change (POST /v1/admin/licenses/:id/change-tier).
  Phase 4.

Tests (+6, total now 72):
- upgrade_quote_returns_perpetual_difference (Standard $25 →
  Pro $75 = $50 = 5000 cents quote, "immediate" effective)
- upgrade_quote_rejects_garbage_key (401, doesn't leak whether
  the target slug exists)
- upgrade_quote_rejects_unknown_target_policy (404)
- upgrade_start_creates_invoice_and_tier_change_row (verifies
  the tier_changes row is written tied to the new invoice; the
  license is NOT yet on Pro until settle)
- webhook_settle_on_tier_change_applies_instead_of_issuing
  (full end-to-end: settle webhook fires → license flips to Pro
  + Pro entitlements appear; license count stays at 1, NO new
  license issued; re-delivery idempotent)
- upgrade_endpoint_rejects_buyer_downgrade (400 "admin-only" —
  the clear-message path the quote function intercepts with;
  Phase 4 will introduce a separate buyer-downgrade path)
2026-05-08 20:06:13 -05:00
Grant f8affdb11f Tier upgrades Phase 2 — quote logic + apply step
Builds on 8ce78ab (Phase 1 schema). Pure module work — no HTTP
endpoints yet (those are Phase 3). Operator-invisible until Phase
3-5 wire up the buyer / admin / UI surfaces.

src/upgrades.rs:
- UpgradeQuote / TierDirection / EffectiveAt structs (serde-ready
  for the future endpoint).
- compute_upgrade_quote(state, license, target_policy) — the
  buyer-facing quote function. Enforces ladder rules:
    * both policies must have non-NULL tier_rank
    * sideways (same-rank) changes rejected — admin-only
    * cross-product target rejected
    * inactive target rejected
    * same-policy noop rejected
    * perpetual downgrades rejected (refund decision = admin-only)
    * recurring → perpetual downgrade rejected (admin-only)
- Branches on perpetual vs recurring:
    * Perpetual upgrade: flat (target - current) listed price diff,
      effective_at = Immediate.
    * Recurring upgrade: prorated (target - current) × days_remaining
      / period_days; effective_at = Immediate; surfaces
      next_renewal_charge for the buyer to see what they'll pay
      going forward.
    * Recurring downgrade: zero-charge today, effective_at =
      next_renewal_at (full current cycle at old price).
    * Free → recurring: full first-cycle price (no proration since
      "remaining value" of free is 0).
- record_tier_change — INSERT helper for the audit row.
- apply_tier_change — UPDATE helper that mutates the license row
  (policy_id, entitlements_json, expires_at, max_machines,
   grace_seconds, is_trial) and any tied subscription
  (policy_id, listed_value, period_days). Recurring → perpetual
  apply also cancels the now-orphaned subscription so the renewal
  worker stops touching it.
- get_tier_change / list_tier_changes_for_license /
  get_tier_change_by_invoice — read helpers (Phase 3 webhook
  handler will use the by_invoice variant).

tier_rank threading:
- models::Policy gains `tier_rank: Option<i64>`.
- POLICY_COLS + row_to_policy include tier_rank with try_get
  Option<i64> + flatten so NULL stays NULL (a valid state) and
  pre-0013 databases also resolve to None.
- repo::create_policy gets a `tier_rank: Option<i64>` param.
- repo::RecurringUpdate gains `tier_rank: Option<Option<i64>>`
  for nullable-patch semantics matching price_sats_override.
- CreatePolicyReq + UpdatePolicyReq accept tier_rank with the
  same shape; range-validated 0..=1000.

tests/upgrades.rs (8 new tests):
- perpetual_upgrade_quote_returns_flat_price_difference
- perpetual_downgrade_is_admin_only (rejection w/ helpful msg)
- quote_rejects_target_with_null_tier_rank
- quote_rejects_same_policy
- recurring_upgrade_prorates_against_time_remaining (asserts
  ~half-of-diff for ~half-of-cycle remaining; tolerance window)
- recurring_downgrade_is_zero_charge_at_next_cycle (verifies
  effective_at lands on next_renewal_at)
- apply_tier_change_mutates_license_and_subscription (Standard
  monthly → Pro annual changes max_machines, entitlements,
  expires_at, sub policy_id + listed_value + period_days)
- record_and_lookup_tier_change_round_trip

Test count: 66 (was 58; +8).
2026-05-08 19:50:04 -05:00
Grant 5d7f68fef8 Recurring subs Phase 6 — cancellation flow (admin + buyer self-serve)
Closes the recurring-subs feature loop: operators can cancel subs from
the admin UI, buyers can self-cancel by submitting their signed
license key. Cancellation is non-destructive — the license stays
valid through end-of-cycle, the renewal worker just stops creating
new invoices because its WHERE filter excludes status='cancelled'.

New API
- GET  /v1/admin/subscriptions             — list (filter: status=...)
- POST /v1/admin/subscriptions/:id/cancel  — operator cancel (audited)
- POST /v1/subscriptions/cancel            — buyer self-service; auth
                                             via license_key in body,
                                             verified by signature

Repo helpers (src/subscriptions.rs)
- get_subscription_by_id
- get_subscription_by_license_id  (1:1 unique on license_id, used by
                                   buyer self-service)
- list_subscriptions(status_filter, limit)
- cancel_subscription              (idempotent UPDATE, returns whether
                                    it actually transitioned)

Behavior details
- Both endpoints fire `subscription.cancelled` webhook with
  actor=admin/buyer so operators can distinguish self-service.
- Audit log differentiates by actor_kind: 'admin_api_key' vs
  'buyer_license_key'.
- Buyer endpoint returns 401 (not 404) on bad/wrong key so a probe
  can't enumerate which licenses have active subs.
- Buyer endpoint returns 401 on revoked or suspended licenses too —
  same reason.
- Admin endpoint returns 200 with `{already: <prior_state>}` on
  re-cancel (idempotency); 404 on unknown sub.

Tests (+4, total now 57)
- admin_cancel_subscription_happy_path: full flow + DB invariants +
  audit row + idempotency
- admin_cancel_unknown_subscription_404s
- buyer_cancel_subscription_via_license_key: full flow + actor_kind
- buyer_cancel_rejects_garbage_key: 401 not 404

Admin UI for the cancel button + subscriptions tab lands in a
follow-up commit (kept this one to the API surface so it's reviewable
in isolation).
2026-05-08 17:53:42 -05:00
Grant c301eacfaa Recurring subs Phase 4 — admin UI + buy-page rendering + Pro-tier gate
Phase 4 surfaces the recurring-subscription schema (migration 0011) and
renewal-worker (Phase 2, commit 7007bf8) through every layer operators
and buyers actually see:

API
- Policy struct + repo gain is_recurring, renewal_period_days,
  grace_period_days, trial_days. RecurringConfig / RecurringUpdate
  helper structs keep create_policy / update_policy signatures
  manageable.
- CreatePolicyReq + UpdatePolicyReq accept all four fields. Validation
  rejects internally inconsistent combos (recurring=true with period=0,
  trial > renewal period, period >5y, grace >90d).
- New tier::enforce_recurring_feature gate. Pro/Patron only — Creator
  and Unlicensed get a 402 with upgrade_url. The gate fires on both
  create-policy and the false→true transition in update-policy.
- list_public_policies now surfaces is_recurring, renewal_period_days,
  trial_days so SDKs and the buy page can render cadence.

Admin UI (web/index.html)
- Create-policy form gets a "Recurring subscription (Pro)" section:
  is_recurring checkbox + cadence preset (monthly/quarterly/etc/custom)
  + grace period + trial days. Live enable/disable: the inputs gray
  out unless the box is ticked, and the custom-days input grays out
  unless "Custom" is selected.
- Edit-policy modal mirrors the same section, pre-populated from the
  policy's current values.
- Policies-list table shows a gold "every Nd" badge alongside the
  trial badge so operators can see at a glance which policies renew.

Buy page (/buy/<slug>)
- Tier cards on a recurring policy render a "Renews monthly/annually/
  every N days" meta line + a "/mo" / "/yr" / "/Nd" suffix on the
  price unit, so the headline reads "$25 / mo" not just "$25".
- First-cycle trial banner shows when trial_days > 0.
- TIERS JSON map exposes is_recurring + renewal_period_days +
  trial_days so the JS price-update path keeps the cadence suffix
  in sync when the buyer clicks between tiers.

Tests (+4, total now 53)
- recurring_policy_blocked_on_creator_tier — 402 + upgrade_url
- pro_tier_creates_monthly_recurring_policy — full create + verify
  via both admin GET and public list endpoint
- recurring_requires_positive_period — validator rejects period=0
- edit_policy_to_recurring_respects_tier_gate — Creator 402 on flip,
  Pro 200 on same flip, name-only PATCH on already-recurring policy
  doesn't re-fire the gate after downgrade

Drive-by: wrap the state-machine ASCII diagram in subscriptions.rs in
a ```text fence so cargo's doc-test runner stops trying to compile box
characters as Rust tokens.
2026-05-08 17:47:55 -05:00
Grant ec2b21d8f7 v0.2.0:3 — durable payment-provider switching (Option B)
Closes the gap from :2 where Connect Zaprite swapped the
in-memory provider but BTCPay would silently re-take active on
the next daemon restart (because the boot-time loader picked
BTCPay first whenever btcpay_config was present, regardless of
operator intent).

What changed:

**New settings key `active_payment_provider`** in the existing
settings table. Records the operator's last explicit choice
('btcpay' | 'zaprite' | NULL = no preference). Both
btcpay_config and zaprite_config can coexist; the flag is what
determines which one the daemon loads.

**Boot-time loader respects the preference.** main.rs now reads
the flag at startup. If set to 'zaprite', Zaprite wins; if set to
'btcpay', BTCPay wins; if unset (legacy installs), falls back to
the previous BTCPay-first ordering. Cross-load fallbacks log a
WARN and try the other provider — operators with a stale flag
pointing at a wiped config don't boot unconfigured.

**Connect endpoints write the preference.**
- finish_connect (BTCPay) now sets the flag to 'btcpay' on
  successful authorize-callback completion.
- ZapriteAuthorize::connect now sets the flag to 'zaprite' on
  successful API-key validation.
- Both Disconnect endpoints clear the flag IF it pointed at the
  provider being disconnected — but leave it alone if it pointed
  at the OTHER provider (different operator intent).

**New endpoints for fast switching without re-Connect:**
- GET /v1/admin/payment-provider/status — both configs' state +
  current preference + runtime active provider, in one call.
- POST /v1/admin/payment-provider/activate { provider: "btcpay" |
  "zaprite" } — flips the active provider and the flag together,
  without going through the full Connect flow. 400 if the named
  provider isn't configured (operator must run Connect first).

**New StartOS Actions** under existing groups:
- "Activate BTCPay" (in BTCPay group)
- "Activate Zaprite" (in Zaprite group)
Both call the new activate endpoint. Operators with both
providers configured can flip back and forth in one click.

**Test:** payment_provider_preference_round_trip pre-seeds both
configs, walks through Activate-Zaprite → Activate-BTCPay →
attempt-Activate-on-wiped-config → bad-provider-name → manual
write/read of the preference key. Pins the contract.

Test count: 42 (was 41; +1).

Migration not needed — settings table from 0005 already has the
key/value/updated_at shape we need.
2026-05-08 16:51:15 -05:00
Grant 9eba309a8f v0.2.0:2 — Zaprite payment provider + recurring subscriptions schema foundation
This release adds Zaprite as an alternative to BTCPay. Operators
can now choose between two payment rails:
- BTCPay: Bitcoin-only, you run the BTCPay Server yourself
- Zaprite: Bitcoin + fiat cards (USD/EUR via Stripe/Square), brokered
  by Zaprite, settles to your connected wallets

Only one is active at a time per Keysat instance. Switching requires
Disconnect → Connect; existing license keys are unaffected. Future
v0.3 work routes per-policy choice (e.g., "free tier via Zaprite,
paid tier via BTCPay") if operators want both, but for v0.2.0:2 it's
either-or.

What's in this release:

**Migration 0011 — recurring subscriptions schema (dormant).**
Adds `subscriptions` and `subscription_invoices` tables, plus
`is_recurring`/`renewal_period_days`/`grace_period_days` (default 7)/
`trial_days` (default 0) on policies. No daemon code uses these
yet — phases 2-6 of RECURRING_SUBSCRIPTIONS_DESIGN.md land in
follow-up commits. Migration regression test covers the additive
contract against populated data.

**Migration 0012 — zaprite_config.** Singleton-row table for the
operator's Zaprite API key + base URL + recorded webhook id.
Mirrors btcpay_config from migration 0002.

**ZapriteProvider implementation.** New module at
src/payment/zaprite/ with client.rs (HTTP, Bearer auth), config.rs
(DB persistence), provider.rs (PaymentProvider trait impl). Maps
Zaprite's currency enum (BTC/USD/EUR) to/from the Money type;
maps Zaprite's order status enum (PENDING/PROCESSING/PAID/COMPLETE/
OVERPAID/UNDERPAID) to ProviderInvoiceStatus.

**Webhook security via externalUniqId round-trip.** Zaprite does
NOT publish a webhook signature scheme (verified May 2026 against
public OpenAPI + dashboard). Their docs explicitly designate
receiver-side idempotency as the security model. Keysat's defense:
attach our local invoice UUID as externalUniqId at order creation,
then trust the webhook only insofar as the order id resolves to
a local invoice in an expected state. Documented in detail in the
payment::zaprite module-level comment + the validate_webhook
docstring.

**Admin endpoints.**
- POST /v1/admin/zaprite/connect: validates the API key by pinging
  GET /v1/orders before persisting; swaps active provider atomically
- POST /v1/admin/zaprite/disconnect: clears stored creds + provider
- GET  /v1/admin/zaprite/status: read-only connection snapshot
- POST /v1/zaprite/webhook: webhook landing route (alias of the
  existing /v1/btcpay/webhook handler since validate_webhook is
  trait-level)

**StartOS Actions** under a new "Zaprite" group: Connect Zaprite,
Check Zaprite connection, Disconnect Zaprite. Operator pastes the
API key into a masked input; daemon validates + saves.

**Tests.** Two new in tests/api.rs (zaprite_webhook_event_parsing
covers the full event-type mapping + missing-id rejection +
malformed-JSON rejection; zaprite_provider_kind pins the
identification). Migration regression test for 0011. Test count
grows 39 → 41.

Operators on BTCPay see no change. Operators wanting Zaprite go
through the StartOS Actions tab → Connect Zaprite, paste their
API key, register a webhook in Zaprite's dashboard pointing at
their public Keysat URL + /v1/zaprite/webhook.

Recurring subscriptions are NOT yet operator-visible — schema only
in this release. Daemon-code that uses the subscriptions tables
(renewal worker, validate-hot-path subscription branch, admin UI)
lands in subsequent commits per the design doc's phased plan.
2026-05-08 16:34:58 -05:00
Grant d8aa9c22b9 Multi-currency Phases 3, 5, 6 — buy page, invoice rate recording, discount currency
Phase 5 (invoice records the rate):
- repo::create_invoice_with_currency takes the listed currency,
  listed value, exchange_rate_centibps, and exchange_rate_source as
  optional params; create_invoice (the legacy form) becomes a thin
  wrapper that passes None for all four. SAT-priced flows are
  unchanged.
- purchase::start now branches on product.price_currency: SAT keeps
  the existing path; USD/EUR calls rates::convert_to_sats and pins
  the listed price + rate to the local invoice row for audit. The
  buyer is still billed in BTC (BTCPay invoice is sat-denominated)
  but the audit trail records what they SAW vs what they were
  charged.
- Test paid_purchase_in_usd_records_listed_currency_and_rate seeds
  a manual rate pin ($50k/BTC), creates a USD-priced product
  ($49.00), runs through purchase, asserts the invoice row carries
  listed_currency='USD', listed_value=4900, rate_centibps=
  500_000_000, source='manual_pin', amount_sats=98_000.

Phase 3 (buy page renders fiat):
- Server-rendered initial price respects product.price_currency:
  USD products show "49.00 USD" (cents converted to display dollars)
  instead of sats. Tier-picker JS still formats per-tier prices in
  sats — that's a v0.3 polish when we plumb the rate into the JS
  render path. Most operators ship single-policy products at first,
  so the static initial render is the high-leverage piece.

Phase 6 (currency-aware discount codes):
- POST /v1/admin/discount-codes accepts optional `discount_currency`
  field ('SAT' default, 'USD', 'EUR'). Whitelisted in the handler.
- repo::create_discount_code is now a thin wrapper around
  create_discount_code_with_currency; the new helper persists
  discount_currency to the column added in 0010. Existing SAT-only
  codes keep working unchanged.

Test count: 37 (was 36; +1 paid_purchase_in_usd test).

Multi-currency design phases 1-6 all shipped (1: schema in :48; 2:
admin UI write in :48-:49; 3: buy page; 4: rate fetcher; 5: invoice
audit; 6: discount currency). Phase 7 (recurring subscriptions
re-quote) is v0.3 territory — needs the recurring-billing scaffolding
from Zaprite first.
2026-05-08 12:21:26 -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 356d17fdde Multi-currency Phase 2 — admin write path (currency picker)
Backend:
- POST /v1/admin/products accepts both forms:
  - legacy: { price_sats: 50000 }
  - typed:  { price_currency: 'USD', price_value: 4900 }
  Whitelist enforced (SAT|USD|EUR). Mismatched legacy + typed → 400
  to catch half-migrated clients sending stale price_sats alongside
  fresh price_value.
- repo::create_product_with_currency: SAT → dual-write price_sats =
  price_value; USD/EUR → price_sats = 0 until first invoice creation
  triggers a rate lookup (Phase 4 + 5).
- Test admin_create_product_accepts_legacy_and_typed_currency_forms
  pins 6 happy/sad paths.

Frontend (Products page):
- Create-product form has a currency picker (sats / USD / EUR).
  Picker swaps the unit hint + step in place.
- Decimal entry on USD/EUR is converted to cents on the way out.
- Products table renders prices via formatProductPrice(): USD
  products show "$49.00" with optional "≈ 75k sats" hint.

Test count: 34 (was 33).
2026-05-08 12:11:36 -05:00
Grant d827b1aaab Opt-in community analytics + admin UI surface
Closes the last T2 plan item. Off by default; toggling on requires
the operator to confirm a collector URL (an empty URL is "armed but
silent"). The toggle lives on the admin Overview page next to the
public-key card — the right place for a privacy-affecting choice
since it's where operators actually live.

What's sent (per the in-card "Show me exactly what gets sent"
disclosure, and pinned by the test):
- install_uuid: random UUIDv4 generated on first opt-in. NOT
  derived from operator_name, store id, public URL, or any
  other identifier. Wipeable via the Reset button.
- daemon_version (CARGO_PKG_VERSION).
- tier (creator/pro/patron/unlicensed) — the same string the
  admin tier endpoint already exposes.
- counts: products, active_licenses, settled_invoices — each
  floored to the nearest 5 (anti-fingerprinting; an exact license
  count uniquely identifies an operator over time).
- uptime_bucket: <1d / 1-7d / 1-4w / >4w (bucketed, not exact).

What's NOT sent (test asserts none of these strings appear in the
preview heartbeat): operator_name, public_url, store_id, api_key,
buyer_email, btcpay_url. Also no product/policy slugs or names, no
license/invoice ids, no fingerprints, no webhook secrets.

Backend:
- src/analytics.rs — heartbeat builder, opt-in check, daily
  background tick (5min initial grace period after boot).
- src/api/community.rs — GET / POST / reset admin endpoints.
- main.rs spawns the background tick unconditionally; the tick
  is a no-op if disabled OR no collector URL configured.

Frontend (web/index.html, Overview page):
- Toggle + collector URL input + privacy disclosure showing the
  EXACT JSON shape that would be sent (renders the live preview
  heartbeat from /v1/admin/community-analytics).
- "Reset install_uuid" button so an operator who's been beaconing
  under one identifier can start fresh.

Also includes the configureBtcpay.ts idempotency change from
v0.1.0:46 (already committed; touched again here only because the
diff includes the .ts file in the same dirty-tree push).

Test count: 32 (was 31; +1 community_analytics_opt_in_and_privacy_contract
which seeds 23 licenses and verifies the heartbeat reports 20 —
proves the floor-to-5 anti-fingerprinting is in effect).
2026-05-08 11:35:50 -05:00
Grant f6ba1c160e Buyer self-service recovery + db-info admin endpoint
Two operator-facing additions, both addressing risks we'd flagged
earlier in the v0.2 plan but hadn't shipped.

**POST /v1/recover (+ GET /recover HTML form).** Lets a buyer who
lost their license key re-derive it themselves by presenting their
invoice id + the email they paid with. Until now, the recovery
flow was "DM the operator with your invoice id and they re-send" —
operator-time scaling badly. With this, the buyer self-serves and
the operator never has to know.

The endpoint takes (invoice_id, email), case-insensitive on email.
Returns a generic 404 on any mismatch — does NOT distinguish
"invoice not found" from "wrong email" so an attacker can't
brute-force email addresses against a known invoice id. Per-IP
rate limited at 10 requests / minute. Audit-logged as
license.recovered with the email's SHA-256 hash so PII isn't
written to the log.

The HTML form at GET /recover is server-rendered, no JS framework,
no cookies — designed for a customer who's just had a catastrophic
failure of their primary computer and reached us from whatever
device they could find.

Test in tests/api.rs:recover_returns_license_key_for_matching_pair
exercises the happy path (case-insensitive email match), the
generic-404 paths (wrong email, missing invoice), the round-trip
(recovered key validates via /v1/validate), and the audit-log
write.

**GET /v1/admin/db-info.** Cheap insurance against the
catastrophic-loss risk: /data/keysat.db is a single SQLite file,
losing it invalidates every license ever issued. StartOS's backup
machinery handles snapshotting; this endpoint gives operators a
sanity-check surface they didn't have before:
  - DB file path + on-disk size
  - last-write timestamp (max across audit_log, invoices, licenses)
  - row counts for products, policies, licenses (total + active),
    invoices (total + settled), machines (active), discount codes,
    audit log entries

Doesn't report when StartOS last backed it up — the daemon has no
visibility into the host's snapshot subsystem. What it gives the
operator is a "I expected ~50 licenses and I see ~50 licenses; the
file is N MB; the last write was 6 hours ago" check.

Test count: 31 (was 30; +1 for the recover test).
2026-05-08 11:05:10 -05:00
Grant f9ef1a854c Webhook DLQ — list failed deliveries and manually retry
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.
2026-05-08 09:38:58 -05:00
Grant e2b296ce29 Migrate purchase::start onto PaymentProvider trait + paid-purchase test
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).
2026-05-08 09:35:41 -05:00
Grant 34704bfa03 Add tier-cap enforcement test
Verifies the 402 PAYMENT_REQUIRED gate on /v1/admin/products fires at
the Creator-tier product cap (5), and that swapping `self_tier` to a
Licensed tier with `unlimited_products` lifts the cap without a
daemon restart. Mirrors what the admin UI's "Activate Keysat license"
flow does at runtime.

Validates two production-correctness invariants:
  - the 402 carries an `upgrade_url` so the SPA can render the
    upgrade CTA inline (rather than a generic error)
  - the failed attempt does NOT leak a row into the products table —
    the cap fires BEFORE the insert
2026-05-08 09:32:53 -05:00
Grant c11764898b v0.1.0:42 — webhook idempotency test + free-purchase test
Two new API integration tests, both targeting production-correctness
invariants worth locking down:

- free_purchase_issues_license_inline: exercises the price=0 shortcut
  (price_sats_override=0 on a "free" tier policy). Verifies the daemon
  synthesizes a settled invoice locally, issues a license inline, and
  the inlined license_key validates round-trip via /v1/validate.

- webhook_settles_invoice_and_issues_license_idempotently: the most
  important new test in this set. A pending invoice + an InvoiceSettled
  webhook → license issued, status flipped. Re-delivering the SAME
  webhook (which providers DO retry, sometimes aggressively) must NOT
  duplicate the license. A duplicated license here means duplicated
  revenue and duplicated revocation surface area — both bad. This test
  pins the invariant.

MockPaymentProvider added to tests/api.rs: a test-only PaymentProvider
impl that bypasses HMAC verification and parses test-supplied JSON
bodies into ProviderWebhookEvent variants. Lets us drive deterministic
settle/expire/invalid events without a real BTCPay roundtrip. Never
compiled into the production binary.

Paid-purchase test deferred: purchase::start still uses the legacy
state.btcpay_client() compat accessor that downcasts to the concrete
BtcpayProvider, which the mock can't satisfy. Documented inline. Slots
in trivially after the trait migration on the v0.3 backlog.

Version bump to v0.1.0:42 with release notes covering everything since
:41 was published: lib.rs library refactor, the original 5 API tests
from 81066df, the 2 new ones above, KEYSAT_INTEGRATION.md restoration.
No daemon-behaviour changes for operators; straight drop-in upgrade
from :41.

Test count: 20 (9 unit + 4 migration + 7 API), up from 13 in :41.
2026-05-08 09:24:57 -05:00
Grant 81066dfe62 Add API endpoint integration tests + library scaffolding
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).
2026-05-08 09:14:27 -05:00