069cf1eb40a05c373320cafe2f21dbbc113fe73e
34 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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). |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
68dfe7f6fc |
Product entitlements catalog (Phase 1: schema + admin + buy page)
Closes the request to make entitlements first-class on products
instead of free-text strings on policies. Operators declare the
closed list of entitlements a product offers — slug + display name
+ optional description — and policies pick from that list with a
click-to-toggle bubble UI. Buy page renders human-readable names
("AI summaries") with descriptions as tooltips, never the raw slug
("ai_summaries").
Schema (migration 0014):
- products.entitlements_catalog_json: nullable JSON column shaped
as [{slug, name, description}, ...]
- Auto-backfill on upgrade: for each existing product, derive a
catalog from the union of its policies' entitlement slugs, with
name = slug.replace('_', ' ') and empty description. Operators
can refine afterward.
- Products with no policy entitlements stay NULL (legacy
free-text mode preserved).
Server:
- Product struct gains entitlements_catalog: Option<Vec<EntitlementDef>>
- repo::set_product_entitlements_catalog (validates lowercase ASCII
slugs, uniqueness, defaults name to slug if empty)
- Product create/update API accept entitlements_catalog;
update uses double-Option PATCH shape so operators can clear
- Closed-list validation: when product has a non-empty catalog,
policy create + update reject any entitlement slug not in the
catalog with a clear error pointing at the right path
- /v1/products/<slug>/policies surfaces entitlements_catalog
in the product object so SDK consumers can render display
names client-side
- Buy page renders entitlement display names + description tooltips
on tier cards (falls back to raw slug for legacy entries that
predate the catalog)
Admin UI:
- New catalogEditor() helper (repeating slug/name/description rows
with add/remove buttons) embedded in product create + edit forms
- New entitlementBubblePicker() helper (click-to-toggle pill chips
showing display name with description tooltip)
- Policy create form: entitlements input swaps based on the chosen
product's catalog — bubble picker when catalog has entries,
legacy textarea otherwise. Rebuilds when operator changes
product.
- Policy edit modal: same bubble-picker-or-textarea swap, scoped
to the policy's product
- Policy list table: entitlement column shows display names
(resolved against the product's catalog) instead of slugs
Migration regression test verifies:
- Backfill correctly unions entitlements across all of a product's
policies, deduplicates, applies name = slug-with-underscores-as-
spaces transformation
- Products with no policy entitlements get NULL (not [])
- Manually-set catalog values round-trip
- Schema is otherwise FK-clean post-migration
Test count: 78 (was 77; +1 for migration_0014_backfills_*).
Phase 2 (SDK updates + integration doc + side-by-side card-grid
policy authoring UI) ships in follow-up commits before v0.2.0:8.
|
||
|
|
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.
|
||
|
|
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)
|
||
|
|
f8affdb11f |
Tier upgrades Phase 2 — quote logic + apply step
Builds on
|
||
|
|
8ce78ab9d3 |
Tier upgrades Phase 1 — schema foundation (dormant)
First step of TIER_UPGRADES_DESIGN.md (Grant + me, parent folder).
Schema-only commit; Phases 2-6 (quote logic, buyer endpoints, admin
endpoints, admin UI, buyer surface) ship in follow-ups.
Migration 0013_tier_upgrades.sql:
1. ALTER TABLE policies ADD COLUMN tier_rank INTEGER. Operator-defined
ladder ordering — higher = better tier. NULL means the policy isn't
in any ladder (existing operators see no behavior change). The
buyer-facing upgrade endpoint will validate
target.tier_rank > current.tier_rank for upgrades, and the reverse
for downgrades. Index on (product_id, tier_rank) supports the
"list this product's policies in ladder order" query.
2. New tier_changes table — one row per upgrade/downgrade. Captures:
- from_policy_id / to_policy_id with FKs into policies
- direction ('upgrade' | 'downgrade', CHECK enforced)
- listed_currency + proration_charge_value (smallest unit) for the
pricing snapshot; invoice_id nullable so comp-mode admin changes
(skip_payment=true) can write a row without an invoice
- effective_at decoupled from created_at so downgrades on recurring
subs can be RECORDED immediately but TAKE EFFECT at cycle end
- actor ('buyer' | 'admin', CHECK enforced) + free-form reason
- Three indexes covering the obvious query paths: by license
(history view), by created_at (operator analytics), partial on
invoice_id WHERE NOT NULL (webhook-handler lookup of
"is this settling invoice a tier-change?").
Migration regression test (8 tests now in tests/migrations.rs, was 7):
- Existing pre-0013 fixtures untouched, tier_rank defaults to NULL.
- tier_changes accepts a row referencing pre-0013 license/policy/invoice.
- CHECK constraints fire: bad direction, bad actor, negative
proration_charge_value all rejected.
- assert_db_clean confirms no FK / integrity drift.
Drive-by: branding design doc (parent folder) bumps its migration
number from 0013 → 0014 to avoid a collision with this one.
Test count: 58 (was 57; +1 for migration_0013_adds_tier_upgrades).
|
||
|
|
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).
|
||
|
|
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
|
||
|
|
7007bf8204 |
Recurring subs Phase 2 — renewal worker (committed, not published)
Implements the renewal lifecycle from RECURRING_SUBSCRIPTIONS_DESIGN.md
phase 2. Operators don't see this yet (no admin UI); the worker
only acts on subscriptions that exist in the schema, and creating
subscription rows still requires direct DB insert. Phase 4 (admin
UI) wires the buyer-facing surface that creates them.
src/subscriptions.rs (new module, ~450 LOC):
- find_due_renewals: subs with status active|past_due whose
next_renewal_at has passed and consecutive_failures < cap
- find_lapsing_subscriptions: past_due subs whose
(next_renewal_at + grace_period_days) is in the past
- mark_lapsed / mark_active_after_settle / mark_renewal_failed:
state-transition helpers
- create_subscription: atomic create-sub + first-cycle invoice
(called by purchase flow when policy.is_recurring; not yet
wired — that's a separate phase)
- on_invoice_settled: helper for webhook handler to flip a sub
from past_due back to active and dispatch subscription.renewed
- find_subscription_for_invoice: lookup helper
- tick: 60s sweep, picks up to 25 due renewals + lapse sweep
- spawn: long-lived background task, mirrors webhooks::spawn_delivery_worker
Renewal flow per due sub:
1. Convert listed_value to sats via rates::convert_to_sats
(identity for SAT subs; live rate fetcher for USD/EUR — per
MULTI_CURRENCY_DESIGN.md "USD-stable / re-quote each cycle"
decision).
2. Get the active payment provider, call create_invoice with
the same trait surface used by one-shot purchases. Works
against BTCPay or Zaprite or any future provider.
3. Persist the local invoice row carrying the rate audit
(listed_currency / listed_value / exchange_rate_centibps /
exchange_rate_source). For SAT subs, rate fields are NULL
(identity conversion isn't worth recording).
4. Insert subscription_invoices linking the invoice to the sub
with monotonic cycle_number.
5. Update sub: status → past_due, next_renewal_at → end of new
cycle, last_renewal_attempt_at → now.
6. Dispatch subscription.renewal_pending webhook to the operator.
On settle (webhook handler): if the invoice is linked via
subscription_invoices, flip sub → active, reset
consecutive_failures to 0, dispatch subscription.renewed.
Failure path: increment consecutive_failures, push next_renewal_at
out by exponential backoff (5min → 30min → 2h → 6h → 12h, capped
at 5 failures ≈ 24h of retries before the worker stops trying).
Operator can see stuck subs via the upcoming admin UI; for now
they show up in the audit log via webhook deliveries.
Lapse path: separate sweep finds past_due subs whose
(next_renewal_at + policy.grace_period_days) is past now, flips
to lapsed, dispatches subscription.lapsed.
Wired into:
- src/lib.rs: pub mod subscriptions
- src/main.rs: subscriptions::spawn(state.clone()) alongside
reconcile + webhooks + analytics
- src/api/webhook.rs: settle path now calls
subscriptions::on_invoice_settled before license issuance —
ordering matters because first-cycle subs create both a sub
row AND a license; we want the sub state correct on the way
to the license-issuance branch
Test: 7 integration tests in tests/subscriptions.rs. Drives the
worker against a MockProvider with fail-on-demand semantics:
- renewal_worker_creates_invoice_for_sat_priced_due_sub: SAT sub
charges listed_value sats verbatim, no rate audit, sub goes
active → past_due, subscription_invoices gets a new cycle row
- renewal_worker_requotes_rate_for_fiat_priced_sub: $25 USD at
pinned $50k/BTC = exactly 50,000 sats; rate audit pinned on
invoice; centibps encoded correctly
- renewal_worker_backs_off_on_failure: failed create_invoice →
consecutive_failures = 1, no invoice created, sub → past_due
- renewal_worker_stops_retrying_at_max_failures: pre-set failures
= MAX, tick is a no-op for that sub
- lapse_sweep_flips_past_due_after_grace: 15-day-old past_due
with grace=7 → lapsed
- settle_webhook_flips_sub_back_to_active: tick creates renewal,
simulate settle, on_invoice_settled flips sub back to active
- tick_is_no_op_when_nothing_due: empty fixture, tick is safe
Test count: 49 (was 42; +7).
NOT bumping version. The recurring-subs feature isn't operator-
visible until phases 4+5 (admin UI for creating recurring
policies + buy page rendering for "$25/month"). Schema is in,
worker runs, but nothing creates subs yet — so this commit
ships dormant.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
4251e96082 |
Migration 0011 — recurring subscriptions schema (committed, not published)
Foundation-only commit. Adds the storage shape for recurring-billing
licenses; daemon code that uses these tables (renewal worker,
validate-hot-path subscription branch, admin endpoints, buy-page
recurring rendering) lands in subsequent commits.
Schema changes (all additive):
- policies gains: is_recurring, renewal_period_days,
grace_period_days (default 7), trial_days (default 0).
- New table `subscriptions` — one row per subscription-backed
license (1:1 via license_id UNIQUE). Tracks the cycle state
machine: active / past_due / cancelled / lapsed.
- New table `subscription_invoices` — one row per renewal-cycle
invoice. Joins subscriptions to the existing invoices table.
UNIQUE(subscription_id, cycle_number) prevents double-billing
the same cycle.
Pricing snapshot (listed_currency / listed_value / period_days)
is FROZEN at subscription creation. Operator changing the
underlying policy's price doesn't affect existing subs; the next
renewal still bills the snapshotted amount. Per
RECURRING_SUBSCRIPTIONS_DESIGN.md.
Migration regression test (migration_0011_adds_subscriptions_without
_breaking_existing_data) seeds realistic fixtures pre-0011, applies
0011, asserts:
- existing policies default to non-recurring with grace=7,
trial=0
- new tables accept rows via FKs into pre-0011 license/policy/
invoice rows
- status CHECK rejects garbage values
- subscription_invoices UNIQUE(sub_id, cycle_number) prevents
duplicate cycle inserts
- foreign_key_check + integrity_check both clean post-migration
Test count: 39 (was 38). Tests all pass:
9 unit + 16 API + 4 crosscheck + 7 migration + 3 worker.
Defaults encoded:
- grace_period_days = 7 (per RECURRING_SUBSCRIPTIONS_DESIGN
open question 1; my recommended default)
- trial_days included as a column from day 1 (per open question
3; cheaper to ship now than migrate later)
- cancellation refund: not a schema concern — just stops next
charge, license stays valid through current cycle (per
open question 2; my recommended default)
If Grant comes back with different answers, the defaults can be
tuned via ALTER COLUMN DEFAULT in a follow-up migration. Existing
subscriptions wouldn't be affected (they snapshot grace_period_days
at creation in their policy_id reference, not directly in the
subscription row — this might need rethinking once the renewal
worker lands; flagged for the next pass).
Not bumped / published — operator-visible only once the daemon
code that uses these tables ships. Ready to publish whenever
Grant approves the open-question defaults.
|
||
|
|
9919fbf8f8 |
v0.1.0:50 — auto-recover from sqlx checksum drift on idempotent migrations
Two operators in a row hit the same crash-loop on upgrade:
Error: running migrations
Caused by:
migration 9 was previously applied but has been modified
sqlx records a SHA-384 of each migration's bytes when first applied,
then verifies the on-disk bytes still match on every subsequent boot.
Cross-build drift (trailing newlines, line-ending normalization, etc.)
produces different bytes for semantically-identical SQL — and sqlx
refuses to start. Recovery required SSHing in and running:
sqlite3 /data/keysat.db "DELETE FROM _sqlx_migrations WHERE version = 9;"
That's bad UX. Worse, every operator going through this version
range hits it once.
Self-heal: db::init now wraps sqlx::migrate!().run() with detection
for MigrateError::VersionMismatch(N) on a constant allowlist of
migrations certified safe to re-run (IDEMPOTENT_MIGRATIONS, just [9]
for now). When triggered, the daemon clears the stale row, retries,
logs a WARN explaining what happened, and continues. No SSH dance.
Allowlist gate is critical — auto-clearing checksums on additive
ALTER TABLE migrations like 0010 would error on retry (SQLite has
no ADD COLUMN IF NOT EXISTS). Only migrations explicitly designed
as drop-and-rebuild (like 0009) and tested via the
`migration_NNNN_is_idempotent` pattern in tests/migrations.rs
qualify.
Regression test in tests/migrations.rs exactly simulates the
production incident:
1. apply all migrations cleanly
2. poison v9's recorded checksum with bogus bytes
3. confirm raw sqlx::migrate! bails (proves the poisoning works)
4. call db::init — must succeed by clearing + re-applying v9
5. confirm v9 + v10 are both recorded with non-poisoned checksums
Test count: 38 (was 37; +1 db_init_self_heals test).
For operators currently stuck on the :49 crash-loop: just upgrade
to :50 from the StartOS marketplace. The :50 daemon will see the
mismatch on first boot, auto-clear v9's row, re-apply (0009 is
idempotent by design), and continue to 0010. No manual sqlite3 needed.
|
||
|
|
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.
|
||
|
|
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). |
||
|
|
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).
|
||
|
|
d8fcb51d1c |
Multi-currency schema foundation (Phase 1 of MULTI_CURRENCY_DESIGN)
Migration 0010 adds the columns needed to price products + policies in something other than satoshis (USD, EUR, BTC at higher denoms) while keeping every existing operator's data behaviorally identical. This is the foundation work; admin UI write path, buy page rendering, and rate fetcher land in subsequent phases. See MULTI_CURRENCY_DESIGN.md at the parent licensing/ folder for the full design. Schema changes (all additive): - products gain price_currency (TEXT NOT NULL DEFAULT 'SAT') and price_value (INTEGER NOT NULL DEFAULT 0). Backfill copies price_sats → price_value on every existing row, so SAT-priced products carry their information identically through the migration. - policies gain price_currency_override (nullable, NULL = inherit from product) and price_value_override (nullable, mirrors the existing price_sats_override). - invoices gain four nullable columns: listed_currency, listed_value, exchange_rate_centibps, exchange_rate_source. NULL on every current row; populated by the daemon when an invoice is created against a fiat-priced product. - discount_codes gains discount_currency (DEFAULT 'SAT'). 'percent' codes are currency-agnostic; 'fixed_sats' and 'set_price' codes use this column to express "$10 off" or "set price to $25" against fiat-priced products. - New index idx_products_currency for future "list products by currency" admin views. Read path: - Product struct gains price_currency + price_value fields (#[serde(default)] for back-compat with any cached/persisted shapes that predate them). - row_to_product extracts the new columns; falls back to SAT/ price_sats if a row predates 0010 (defensive — migration always runs at boot, but no reason to crash if it didn't). - All four product SELECTs add the new columns. Write path (legacy SAT-only callers): - create_product dual-writes price_sats AND price_value to the same value, with price_currency = 'SAT'. - update_product dual-writes price_sats and price_value when the caller passes a new sat price. Migration regression test: - migration_0010_backfills_existing_products_to_sat seeds three products (free, $100, $2500-equivalent) and a policy with a sat override BEFORE 0010 runs, applies 0010, asserts every row ends up with price_currency = 'SAT' and price_value = price_sats. Catches any future change that breaks the backfill contract. - migration_0009_is_idempotent now pinned to 0009 by filename (was: "the last migration"). 0010+ are not idempotent (ALTER TABLE ADD COLUMN can't be retried in SQLite); the idempotency test is specifically for 0009 because that migration's whole point was being safely re-runnable. Test count: 33 (was 32; +1 migration_0010_backfills test). Decisions locked in (per MULTI_CURRENCY_DESIGN open questions): - Default currency on new products: SAT. Operators explicitly pick USD for fiat-priced products. - Multi-currency available to all tiers (NOT gated behind Pro/ Patron) — the right product call. - Rate source priority: Kraken → Coinbase → CoinGecko (lands in Phase 4 of the design). - Recurring subscriptions: SAT-priced subs charge the same sat amount each cycle (no rate adjustment needed); USD-priced subs re-quote each cycle so the dollar amount is stable. |
||
|
|
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). |
||
|
|
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).
|
||
|
|
655e0d51f8 |
Daemon-side wire-format crosscheck
Loads tests/crosscheck/vector.json (the same file the TS, Python, and Rust SDKs each test against independently) and verifies the daemon's crypto::parse_key produces field-by-field identical values. What was missing: the SDKs each ran their crosscheck against the shared vectors, but the **daemon itself** never did. The daemon shares no parser code with the SDKs (separate trees, separate implementations of the same byte layout), so drift in the daemon's parser could ship undetected until an SDK on the wire couldn't validate a daemon-issued key. Four tests, one per fixture in vector.json (v1 legacy fingerprint- bound, v2 trial with entitlements, v2 perpetual unbound), plus a sanity check that publicKeyPem is present. Each fixture asserts: version, product_id UUID, license_id UUID, issued_at, expires_at, flags + derived `is_fingerprint_bound`/ `is_trial` getters, entitlements (order-sensitive), and the 32-byte fingerprint_hash bytes hex-encoded. When `fingerprintRaw` is provided and binding is active, hashes the raw fingerprint with crypto::hash_fingerprint and asserts the result matches the wire bytes — pinning the SHA-256 contract the SDKs depend on. Signature verification is intentionally out of scope: the unit tests in src/crypto/mod.rs already prove daemon's sign/verify roundtrip works, and the SDKs prove the same key verifies in three independent crypto implementations. The parser-to-fields contract is what hadn't been pinned from the daemon's side, and what this file enforces. Test count: 30 (9 unit + 4 migration + 10 API + 3 worker + 4 crosscheck), up from 26. |
||
|
|
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).
|
||
|
|
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.
|
||
|
|
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).
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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).
|
||
|
|
116ed0d1f8 |
v0.1.0:41 — second hotfix to migration 0009; migration regression tests
The v0.1.0:40 migration was correct on clean installs but crashed at COMMIT on any database with rows in discount_redemptions: SQLite's deferred FK check saw the dropped parent's bookkeeping as unsatisfied even after the rename. Fix is to rebuild discount_redemptions in the same transaction (stash → drop → rebuild → restore) plus orphan cleanup. Migration is idempotent; operators on :40 with a checksum mismatch recover by deleting the version=9 row from _sqlx_migrations and restarting. Lands the missing migration test scaffolding too. The four tests in licensing-service/tests/migrations.rs apply migrations against a realistic populated database (products, policies, invoices, licenses, machines, discount codes, redemptions, webhooks, tip attempts). The regression test fails with the exact 787 error against the v40 migration — would have caught the bug pre-release. KEYSAT_INTEGRATION.md is removed from this repo; it now lives in the parent licensing/ folder. |