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.
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.
Both tabs now group by product (matching the per-product card
sections in Products + Policies), with product-filter pills + per-
product counts at the top. Multi-product instances see one section
per product with a status breakdown subtitle ("3 active · 1
revoked · 2 expired"); single-product instances continue to see a
flat table with no chrome overhead. Search results bypass grouping
(search is global across all products).
Three new shared helpers added at the top of the script:
- clickToCopy(fullValue, displayLabel) — clickable code element
that copies the full ID to clipboard with a "✓ copied"
indicator. Replaces the older hover-to-see-full-id UX for
license / subscription IDs.
- relativeDate(rfc3339, opts) — renders an RFC3339 timestamp as
a human-relative string ("in 3 days" / "12 hours ago") with
the absolute timestamp in a hover tooltip. Applied to license
issued/expires + subscription next_renewal.
- reasonModal({title, message, warning, confirmLabel,
confirmVariant}) — inline overlay-card replacement for the
native prompt() / confirm() dialogs. Used by:
* Subscription cancellation flow
* License suspend / unsuspend / revoke flows
Same UX language as the Change Tier modal.
Subscriptions tab specifics:
- Product filter pills with per-product counts (filtered by
active status filter so the counts reflect what the operator
is currently viewing).
- Status filter pills gain counts (Active (3), Past due (0), etc.)
- New Product column shows display name + slug.
- Status badges have hover tooltips explaining each state's meaning.
- Cancel button uses reasonModal instead of prompt().
Licenses tab specifics:
- Quick-stats row: Licenses / Active / Revoked / Expiring < 30d.
Scope follows the active product filter; hover "?" icons
define each stat. Mirrors the Overview dashboard style.
- Search affordance preserved; search results render as a single
flat table titled "Search results" (not grouped by product).
- Manual-issue form's hint blocks replaced with help icons on
every field. Compact-form treatment to match Products + Policies.
- Suspend / unsuspend / revoke buttons use reasonModal with
per-action context (irreversible warning on revoke, etc.)
instead of confirm() + prompt() double-dialog.
- Entitlements rendered with display name primary + description
tooltip (resolves against the product's catalog from
/v1/products's response).
Pure UI release. 78/78 tests still pass. No schema, SDK, or
behavior change.
The Policies tab gets the redesign Grant asked for: replace the
table view + verbose disclosure form with a card grid where each
existing policy renders as a buy-page-style tier card sitting next
to a dashed "+ Add tier" placeholder. Click the placeholder, it
morphs into an editable draft tier card with inline form fields;
submit Create on the card and it flips into a read-only preview.
Multiple drafts can coexist for parallel multi-tier authoring with
side-by-side comparison.
New JS helpers:
- helpIcon(text) — small "?" hover tooltip for compact form labels
- slugify(s) — URL-safe slug derivation from display name
- renderTierCard(pol, product, onMutate) — read-only buy-page-style
preview card with Edit / Hide-Show / Delete actions
- renderAddTierCard(onClick) — dashed placeholder with "+" affordance
- renderDraftTierCard(product, onCommit, onCancel) — inline editable
card with name + slug + price + duration + entitlement bubble
picker + recurring/trial toggles
- renderPolicyCardGrid(product, policies, byPolicyCounts, onMutate) —
ties them together. Submitting "+ Add tier" appends a fresh
placeholder, so operators can keep clicking to author multiple
tiers in one session.
formInput() upgraded:
- New `help:` option renders a helpIcon next to the label (replaces
verbose hint text under the input)
- New `placeholder:` option for cleaner empty-state cues
Auto-slug:
- Product create form's Display name field mirrors a slugified
version into the Slug field as the operator types — until they
manually edit the slug, which arms a "userOverridden" guard so
manual edits stick. Re-arms when the slug field is cleared.
Legacy "Create a new policy" disclosure form unsurfaced from
the Policies route — the card grid replaces it. Advanced fields
(custom grace seconds, tip recipient, tier rank) still live on the
existing Edit modal of an already-committed tier card. Power-user
flow: card grid creates the basics, Edit modal refines.
Test count unchanged (78). UI-only release.
Notes cover the entitlements catalog feature shipped in 68dfe7f
plus the four SDK 0.3.0 cuts (TS / Rust / Python / Go) that
surface the catalog on listPublicPolicies. Phase 2 (side-by-side
card-grid policy authoring UI) is queued for v0.2.0:9.
KEYSAT_INTEGRATION.md section 8 grows a subsection explaining the
catalog mechanics: bubble picker, buy page rendering, SDK surface,
catalog-stability rule.
Test count: 78 (unchanged from :7 except for migration_0014 already
counted in the prior commit).
The Start9 registry card was still showing "Keysat — self-hosted
Bitcoin-paid software license server" while keysat.xyz now leads
with "Bitcoin-native self-hosted licensing service for software
creators." Operators landing on the registry from the marketing
site got a jarring tagline mismatch.
Aligned everywhere the old copy was hardcoded:
- startos/manifest/i18n.ts (short + long descriptions — these
drive the registry card)
- assets/ABOUT.md (in-StartOS About panel)
- README.md (root + licensing-service/)
- licensing-service/Cargo.toml description
Long description also picked up two updates that should have
landed when the features did but never made it into the marketing
copy:
- Zaprite mention (Bitcoin + cards) alongside BTCPay
- Recurring subscriptions + in-place tier upgrades
Pure copy change. No code, no behavior, no schema. Republishing as
:7 because the registry card text lives inside the .s9pk and
won't refresh on operators' boxes without a version bump.
Pure UX bundle from the testing batch. None individually changes
behavior; together they remove a half-dozen sharp edges.
1. Policy-list duration column: human-readable
`31536000s` / `604800s` / `0s` are now `1 year` / `1 week` /
`perpetual`. New `fmtDuration()` helper handles common cadences
(1 day, 1 week, 1 month, 3 months, 6 months, 1 year, 2 years)
with arithmetic fallbacks for non-canonical values. Grace
column gets the same treatment with "none" for 0.
2. "Preview buy page" button per product header
The Policies tab's per-product card now has a "Preview buy
page" button on the right side of the header (when ≥ 1
public+active policy exists). Opens /buy/<slug> in a new
tab. tableCard() helper grew an optional headerAction param.
3. Buy page tier card: "Select" → "Selected"
When a tier becomes the active selection, its button label
flips to "Selected" while other tiers' buttons stay "Select".
Combined with the existing .selected card-border styling
gives buyers an unambiguous "yes, this tier is what's tied
to the price card below" cue.
4. Licenses page POLICY column shows display name
Was showing slug (`recurring`, `core`, `creator`); now shows
the operator-set display name (Recurring Pro, Core, Creator)
primary, with the slug as a smaller mono-font line below.
Operators see what the buyer sees while keeping the slug
visible for SDK reference. (Subscriptions tab already
handled this pattern; this brings Licenses in line.)
5. Change Tier dropdown: "(current)" annotation
Current tier now appears in the dropdown but with " · current"
appended and `disabled` attribute set. Operator sees what
they're starting from but can't pick the no-op. Auto-selects
the first SELECTABLE option so the modal opens with a valid
target ready. formSelect() helper grew per-option `disabled`
support.
6. Single "Switch active payment provider" StartOS action
The two old "Activate BTCPay" / "Activate Zaprite" actions
collapsed into one dropdown-driven action. Operators saw the
pair as confusing — both appeared alongside Connect /
Disconnect / Status, and operators couldn't tell at a glance
which one was currently active. New action pre-fills the
dropdown with the currently-active provider so opening it is
immediately informative.
Old action ids retained as visibility:'hidden' shims for
back-compat with any operator scripts pointing at them.
Test count unchanged; UI-only changes don't touch any test
fixtures.
Bundle of bugfixes from the P1 testing batch. None individually
huge; together they close several "tested it, hit a sharp edge"
items.
1. Change-tier modal — kill the paid path from UI
The Apply-as-comp toggle is gone. Admin tier changes always
apply as comp now. The reasoning (per Grant's testing): admin
tier changes are operator-driven, payment has either already
happened off-rails or it's a comp; the "admin generates
invoice and forwards URL" flow is a tiny niche that just
produces orphan invoices when the modal gets dismissed.
Buyers who want to pay use the SDK's /v1/upgrade.
The API path is unchanged for back-compat with scripted
operators (skip_payment defaults to true here).
2. Change-tier modal — downgrade detection + warning banner
Detects target.tier_rank < current.tier_rank (or price-diff
when ranks aren't set), renders a yellow warning card listing
the entitlements the buyer is about to lose, and confirms via
browser dialog before submit. Operator sees what they're
doing.
3. Self-tier guard on admin change-tier
POST /v1/admin/licenses/<id>/change-tier rejects when <id>
is the daemon's own self_license. Avoids the recursion Grant
hit when trying to downgrade himself: the on-disk signed key
is the source-of-truth at boot, so the DB tier_change just
produces a half-applied state. Error message points at the
right paths (re-mint via master Keysat OR rename
/data/keysat-license.txt for testing). With the P0 self-tier
live-refresh in place the recursion is now fully resolved
anyway, but the guard is good belt-and-suspenders for
operator clarity.
4. Zaprite webhook — full URL in copy + persistent action
- The Connect Zaprite action now shows the EXACT
https://your-keysat-url/v1/zaprite/webhook URL to paste
into Zaprite's dashboard. Previous copy showed a
placeholder "<your Keysat public URL>/...", which Zaprite's
form rejects (it requires full https://). Daemon's
/v1/admin/zaprite/connect now returns webhook_url; the
action displays it.
- New "Show Zaprite webhook setup" StartOS Action — operators
who skipped the step on first connect, or who lost the
output, can run this any time and get the URL again.
- Full explainer of what webhooks unlock vs polling-only:
"without webhooks, Keysat polls /v1/orders every 60s, so
license issuance lags settle by up to a minute; with
webhooks, ~1s." Lives on /v1/admin/zaprite/status response
as `webhook_explainer` + in the action's display text.
5. Connect-while-connected short-circuit
POST /v1/admin/zaprite/connect now returns 409 Conflict with a
clear "already connected — disconnect first" message instead
of silently overwriting an existing config. (BTCPay's
start_connect already had this guard since the durable
provider switch work.)
6. Lightning vs on-chain copy on the wait page
/thank-you was hard-coded to "next block confirms" — wrong
for Lightning payments (instant) and confusing in the common
case where buyers paid via Lightning and saw a "waiting for
block confirmation" message. Updated to: "Lightning settles
in seconds; on-chain typically settles in 10-20 minutes (one
block confirmation)." Method-aware copy (parsed from the
provider's invoice payload) is a deeper fix but out of scope
here — this gets the operator-facing accuracy right today.
Test count unchanged; all 77 still passing.
Bump with notes covering the active_payment_provider preference,
the new Activate <provider> actions, and the symmetric Disconnect
handling.
Test count: 42.
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.
Bump to v0.2.0:2 with notes covering Zaprite as second payment
provider, migration 0011 (recurring subs schema dormant), 0012
(zaprite_config). Test count 41.
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.
Per operator feedback: the discount-code field on /buy/<slug> was
showing 'FOUNDERS50' as a placeholder, which confused buyers (some
tried it as a real code, some assumed Keysat shipped a default
discount). Empty placeholder now; buyers paste their actual code.
No semantic change. Wrapper-only revision; daemon binary unchanged
beyond the embedded HTML template.
Swaps the version graph's current pointer from v0_1_0 to v0_2_0.
v0.1.0 stays in `other` so operators on the alpha line can upgrade
through the StartOS marketplace.
Per CUTTING_V0.2.0.md the steps are:
1. swap versions/index.ts (this commit)
2. npm run check (passed)
3. make x86 (next)
4. publish.sh (next)
What v0.2.0:0 represents — see the release notes in
startos/versions/v0.2.0.ts. Headlines: web admin SPA replaces
Actions for day-to-day work; multi-currency pricing functional
end-to-end; buyer self-service recovery; opt-in community
analytics; webhook delivery DLQ visible in dashboard;
PaymentProvider trait abstraction makes Zaprite drop-in for v0.3;
five-language SDK parity (daemon + Rust + TS + Python + Go).
Bump with notes covering the SPA polish batch + edit-product currency
support. Last polish pass before v0.2.0:0 cutover.
Test count unchanged at 38. Straight drop-in upgrade from :50.
Bump version with release notes covering Phases 2-6 of the multi-
currency design (admin UI write path, buy page fiat rendering, rate
fetcher, invoice rate recording, currency-aware discount codes).
Operators can list products in USD/EUR and accept BTC; the daemon
converts at invoice creation and pins the rate.
Test count: 37. Straight drop-in upgrade from :48.
Bump version with release notes for migration 0010 (additive multi-
currency columns + backfill) and the model/repo updates wiring
the new fields into the read/write paths.
Test count: 33. Straight drop-in upgrade — no admin action,
backfill runs automatically in the migration transaction.
Bumps version with release notes covering:
- Community analytics opt-in (admin Overview surface, off by default,
full privacy disclosure including a live preview of the exact
JSON heartbeat that would be sent)
- Floor-to-5 anti-fingerprinting on counts pinned by test
- Draft v0.2.0:0 release notes parked at startos/versions/v0.2.0.ts
- CUTTING_V0.2.0.md cutover guide
Test count: 32. Straight drop-in upgrade from :46.
Adds startos/versions/v0.2.0.ts as a draft milestone version entry,
ready to swap in as `current` when we're ready to cut. NOT yet wired
into the version graph at versions/index.ts — flipping that switch
is a release decision (one-line change there, then make x86 +
publish), and the draft sits parked so we can iterate on the
release-notes content without committing to the cut.
Format note: the SDK's VersionInfo.of() expects releaseNotes as a
LocaleString (Record<string, string>), not the string[] form
v0.1.0.ts uses. The new file uses the modern shape; v0.1.0.ts keeps
its existing form to avoid churn on the alpha line.
CUTTING_V0.2.0.md walks the operator (or future me) through the
4-step cutover: edit versions/index.ts to swap in v0_2_0, npm run
check, make x86, publish. Plus rollback notes if anything goes
sideways post-cut.
Why park rather than cut now:
1. The user said "prepare for the version 0.2 plumbing" — that's
"prepare" not "do". The cutover is intentional in the user's
workflow, not bundled into a routine push.
2. Cutover changes how the StartOS marketplace renders the upgrade
dialog to existing :N installs; best to QA the release-notes
content first.
3. SDK migration-API behavior on the upstream version bump is
worth verifying on a test install before flipping for everyone.
The v0.2.0 release notes themselves are written conservatively —
they describe what's already shipped and stable in the alpha line
through :47, not aspirational v0.3 features.
Closes the last T1 BTCPay UX gap from V0.2_PLAN. Connect now checks
/v1/admin/btcpay/status first; if a connection exists, returns a
clear "already connected" guidance message pointing the operator at
Disconnect → Connect for re-authorize cases. Without this guard,
re-clicking Connect spawned a new webhook subscription on BTCPay's
side every time, leaving orphan webhooks BTCPay would keep trying
to deliver to.
The Go SDK has been written and verified — all 4 crosscheck tests
pass against the shared tests/crosscheck/vector.json (the same file
the Rust/TS/Python SDKs and the daemon test against). Pure stdlib,
zero third-party dependencies. Hosted in its own repo at
github.com/keysat-xyz/keysat-client-go (private during alpha).
This release IS the 5th-language milestone: daemon + Rust + TS +
Python + Go all agree byte-for-byte on the LIC1 wire format.
Daemon binary unchanged — wrapper-only revision.
Bump version with release notes covering the two operator-facing
additions in f6ba1c1:
- POST /v1/recover (+ GET /recover HTML form) for buyer self-service
- GET /v1/admin/db-info for db health snapshot
Test count: 31 (was 30). Straight drop-in upgrade from :44.
Bumps version with release notes covering everything since :43:
- Webhook DLQ visible in admin SPA with one-click retry
- reconcile.rs + tipping.rs migrated onto PaymentProvider trait
(production refactor; daemon's non-test code now contains zero
calls to the BTCPay-specific compat accessors)
- 3 worker integration tests pin the retry/dead-letter behavior
empirically against real HTTP receivers
- 4 daemon-side crosscheck tests pin the wire-format parser
against the same vector.json the SDKs use independently
Test count: 30 (was 23). Straight drop-in upgrade from :43.
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.
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.