Files
keysat/startos/versions/v0.1.0.ts
T
Grant 7ce30008ff v0.1.0:47 — opt-in community analytics + v0.2.0:0 plumbing parked
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.
2026-05-08 11:42:28 -05:00

471 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Current version of the package. Migrations get added here as versions
// increment.
//
// Version-string format is ExVer: `<upstream>:<downstream>`. Downstream
// revision is bumped for wrapper-only or daemon-only changes that don't
// alter on-disk data shape (we use SQLite migrations for schema changes
// rather than ExVer-level migrations).
import { VersionInfo } from '@start9labs/start-sdk'
export const v0_1_0 = VersionInfo.of({
version: '0.1.0:47',
releaseNotes: [
`Alpha-iteration revision 47 of v0.1.0 — Opt-in community analytics, plus draft v0.2.0:0 release notes parked for the upcoming milestone cut.`,
``,
`**Opt-in community analytics.** Off by default. Toggle on the admin Overview page sends a daily anonymous heartbeat to a configurable collector URL, with full disclosure of exactly what gets sent (and what doesn't). Closes the last T2 v0.2-plan item.`,
``,
`Heartbeat shape (asserted by test):`,
` - install_uuid: random UUIDv4 generated on first opt-in. NOT derived from operator_name, store id, or public URL. Wipeable via the Reset button.`,
` - daemon_version + tier label (creator/pro/patron/unlicensed).`,
` - counts: products / active_licenses / settled_invoices, each FLOORED TO THE NEAREST 5 to prevent fingerprinting an operator by their exact license count over time.`,
` - uptime_bucket: \`<1d\` | \`1-7d\` | \`1-4w\` | \`>4w\`.`,
``,
`Never sent (test asserts none of these strings appear in the 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.`,
``,
`The "Show me exactly what gets sent" disclosure on the admin card renders the LIVE preview heartbeat — no pretend examples, the operator sees exactly the JSON the daemon would POST. Toggle requires confirming a collector URL; without one, the toggle being on is "armed but silent" (daemon never beacons until both are set).`,
``,
`**v0.2.0:0 plumbing parked.** \`startos/versions/v0.2.0.ts\` is drafted with milestone release notes, ready to swap into the version graph when we cut. \`CUTTING_V0.2.0.md\` documents the 4-step cutover and rollback. NOT wired in as current yet — that's the release decision, separate from this routine increment.`,
``,
`**Test count: 32** (was 31; +1 community_analytics_opt_in_and_privacy_contract — seeds 23 licenses, asserts the heartbeat reports 20 active_licenses, proving the floor-to-5 anti-fingerprinting is in effect).`,
``,
`**Upgrade path.** v0.1.0:46 → v0.1.0:47 is a straight drop-in. No new migrations, no schema changes. The community-analytics toggle is OFF on first boot — operators must explicitly opt in before any heartbeat is sent.`,
``,
`Alpha-iteration revision 46 of v0.1.0 — Idempotent BTCPay Connect, plus the Go SDK is now part of the published toolchain.`,
``,
`**Idempotent Connect.** "Connect BTCPay" no longer blindly initiates a fresh authorize flow when Keysat is already connected. It now checks \`/v1/admin/btcpay/status\` first; if a connection exists, it returns a clear "already connected" message that points the operator at "Disconnect BTCPay" → "Connect BTCPay" for the re-authorize case. Closes the v0.2-plan T1 item that was the last outstanding BTCPay UX gap. Without this, re-clicking Connect spawned a new webhook subscription on BTCPay's side every time, leaving orphan webhooks BTCPay would keep trying to deliver to.`,
``,
`**Go SDK lands.** A pure-Go (stdlib only, no third-party deps) implementation of the LIC1 wire format goes live alongside this release at \`github.com/keysat-xyz/keysat-client-go\`. Verified byte-for-byte against the same \`tests/crosscheck/vector.json\` the Rust, TypeScript, Python SDKs and the daemon itself test against — all four crosscheck fixtures (v1 legacy, v2 trial with entitlements, v2 perpetual unbound, plus end-to-end PEM-load → ParseAndVerify roundtrip) pass. Five independent implementations of the wire format now agree.`,
``,
`Go SDK API: \`keysat.ParseKey\`, \`keysat.Verify\`, \`keysat.ParseAndVerify\`, \`keysat.HashFingerprint\`, \`keysat.LoadPublicKeyPEM\` for offline use; \`keysat.Client.Validate\` and \`keysat.Client.PublicKey\` for online checks. Idiomatic Go method receivers on \`LicensePayload\` (\`IsTrial\`, \`IsFingerprintBound\`, \`IsExpiredAt\`, \`HasEntitlement\`).`,
``,
`**Daemon binary unchanged.** This is a wrapper-only revision — no Rust source files moved between :45 and :46. Test count remains 31 on the daemon side; the Go SDK's 4 crosscheck tests run independently against \`go test ./...\`.`,
``,
`**Upgrade path.** v0.1.0:45 → v0.1.0:46 is a straight drop-in. No new migrations, no schema changes.`,
``,
`Alpha-iteration revision 45 of v0.1.0 — Buyer self-service license recovery and a database-health admin endpoint. Two operator-facing additions that close real friction points the v0.2 plan called out.`,
``,
`**Buyer self-service recovery.** Until now, the recovery flow for "I lost my license key" was "DM the operator with your invoice id and we re-send" — operator-time scaling badly. v0.1.0:45 ships \`POST /v1/recover\` and a server-rendered \`GET /recover\` HTML form. A buyer enters their invoice id (handed to them at checkout) and the email they paid with; if both match a settled invoice in the daemon's database, the same signed license key is re-derived and returned. No support ticket, no operator involvement.`,
``,
`Security shape: case-insensitive email comparison. 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 limit at 10 requests/minute. Each recovery is audit-logged as \`license.recovered\` with the email's SHA-256 hash so the log doesn't store PII. The HTML form has no JS framework dependency, no cookies — designed for a customer who's just had a catastrophic failure of their primary computer.`,
``,
`Operators can point customers at \`https://your-keysat.example/recover\` from their thank-you emails or in-app license-management screens.`,
``,
`**GET /v1/admin/db-info.** Cheap insurance against the catastrophic-loss risk on \`/data/keysat.db\`. Reports the file's path + on-disk size, the most-recent-write timestamp (max across audit_log, invoices, licenses), and operator-meaningful row counts (products, policies, total + active licenses, total + settled invoices, active machines, 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 is a "I expected ~50 licenses and I see ~50 licenses; the file is N MB; the last write was 6 hours ago" sanity check before any of those numbers go wrong unnoticed.`,
``,
`**Test count: 31** (added one integration test exercising the full recovery flow including the round-trip "recovered key validates via /v1/validate" assertion).`,
``,
`**Upgrade path.** v0.1.0:44 → v0.1.0:45 is a straight drop-in. No new migrations, no schema changes, no behaviour changes for existing endpoints.`,
``,
`Alpha-iteration revision 44 of v0.1.0 — DLQ visible in the admin UI, payment-provider trait migration completes, worker behaviour pinned by tests, and the daemon's wire-format parser is now cross-checked against the shared SDK vectors. Test count: 30 (was 23). Operator-visible: failed webhook deliveries now show up in the dashboard with one-click retry.`,
``,
`**Webhook DLQ in the dashboard.** The admin endpoints from :43 (\`GET /v1/admin/webhook-deliveries\`, \`POST /v1/admin/webhook-deliveries/:id/retry\`) were operator-actionable via curl but invisible in the UI. The Webhooks page now has a "Delivery history" section directly below the registered endpoints, defaulting to the "Failed (DLQ)" filter so the problem case is what you see first. Each row shows created-at, event type, status badge, attempt count, last status code, and the last error inline beneath the status badge — no separate "details" view to chase. Non-delivered rows get a Retry button that re-queues via the existing endpoint; the worker picks it up on its next 5s tick.`,
``,
`**PaymentProvider trait migration completes.** v0.1.0:43 migrated \`purchase::start\` off the legacy \`state.btcpay_client()\` compat accessor onto the abstract \`state.payment_provider()\` trait. v0.1.0:44 finishes the job for the other two consumers: \`reconcile.rs\` (the every-60s loop that catches missed BTCPay webhooks) and \`tipping.rs\` (the LN-payout flow for policy tip recipients). Both now go through \`provider.get_invoice_status()\` / \`provider.pay_lightning_invoice()\` returning typed enums instead of raw JSON. The daemon's non-test code now contains zero calls to the BTCPay-specific compat accessors. Zaprite's drop-in (v0.3) needs only to implement the trait — no remaining BTCPay-specific assumptions in the call sites. The compat accessors stay on \`AppState\` for v0.2 (no need to break things gratuitously) but are now dead code in the production path.`,
``,
`**Worker behaviour pinned by integration tests.** Three new tests in \`tests/worker.rs\` drive the outbound-webhook worker (\`webhooks::tick\`) directly against tiny in-process axum receivers that return programmable status codes. Verifies: (1) a 500 response increments \`attempt_count\` and schedules a retry with \`next_attempt_at\` set; (2) crossing the 10-attempt cap correctly sets \`next_attempt_at = NULL\` (the dead-letter signal that :43's admin DLQ filter matches against); (3) a 2xx response stamps \`delivered_at\`. \`webhooks::tick\` is now \`pub\` so future tests can drive it synchronously without waiting on the 5s background-task interval.`,
``,
`**Daemon-side wire-format crosscheck.** \`tests/crosscheck.rs\` loads the shared \`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 for all three wire-format variants (v1 legacy, v2 trial with entitlements, v2 perpetual unbound). 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. This closes the loop. When \`fingerprintRaw\` is provided, also cross-checks that \`hash_fingerprint(raw)\` equals the wire bytes — pinning the SHA-256 contract the SDKs rely on.`,
``,
`**Upgrade path.** v0.1.0:43 → v0.1.0:44 is a straight drop-in. No new migrations, no schema changes, no behaviour changes for buyers or for any wire format. Operators gain the new dashboard view; nothing else moves.`,
``,
`Alpha-iteration revision 43 of v0.1.0 — Webhook delivery DLQ, purchase-flow refactor onto the PaymentProvider trait, and three more integration tests. Operator-visible: there's now an admin surface for inspecting and retrying failed outbound webhook deliveries.`,
``,
`**Webhook DLQ.** The delivery worker has always retried failed outbound webhooks with exponential backoff up to 10 attempts (5s, 10s, 30s, 1m, 5m, 15m, 30m, 1h, 2h, 6h), then set \`next_attempt_at = NULL\` and stopped. Until now, those "dead-lettered" rows sat in the database forever with no operator-facing surface — a subscriber endpoint that was down for >6h during a license-issuance burst silently lost those events. v0.1.0:43 adds:`,
``,
`\`\`\``,
`GET /v1/admin/webhook-deliveries?status=failed`,
`POST /v1/admin/webhook-deliveries/:id/retry`,
`\`\`\``,
``,
`The list endpoint accepts an optional \`endpoint_id\` filter, an optional \`status\` filter (\`pending\` | \`delivered\` | \`failed\` | \`all\`), and a \`limit\` (default 100, max 500). Newest-first ordering. The retry endpoint resets the row's attempt_count to 0, clears delivered_at + last_error, and sets next_attempt_at to now so the worker picks it up on the next 5s tick. Audit-logged as \`webhook_delivery.retry\`. The worker itself is unchanged; this is operator-actionable infrastructure on top of the existing retry semantics. The admin SPA will surface a "Failed deliveries" tab in v0.1.0:44+.`,
``,
`**Purchase flow migrated to the PaymentProvider trait.** \`purchase::start\` previously called \`state.btcpay_client().await?.create_invoice(amount, metadata, redirect)\` — the legacy compat path that downcasts the active provider specifically to the concrete \`BtcpayProvider\` type. It now calls \`state.payment_provider().await?.create_invoice(CreateInvoiceParams { ... })\` through the abstract trait. Provider-specific concerns (BTCPay's checkout-URL host 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 call path now serves any future provider (Zaprite in v0.3, etc.) without fork/copy. No buyer-visible change; same checkout URLs, same invoice ids.`,
``,
`**Three new integration tests.** Test count: 23 (9 unit + 4 migration + 10 API), up from 20 in v0.1.0:42.`,
``,
` - \`tier_caps_block_at_creator_limit_and_unlock_after_upgrade\` — 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. The 402 carries \`upgrade_url\` so the SPA can render the upgrade CTA inline.`,
``,
` - \`paid_purchase_creates_invoice_via_provider\` — previously deferred per :42's release notes because the legacy compat path prevented MockPaymentProvider from substituting. The trait migration above unblocks it. Verifies the daemon delegates invoice creation to the provider, the returned provider_invoice_id is stamped on the local invoice row, the buyer-facing checkout_url is whatever the provider returned, and no license is issued before the settle webhook fires.`,
``,
` - \`webhook_dlq_lists_failed_and_retry_requeues\` — seeds three deliveries directly via SQL (one each: delivered, pending, dead-lettered), exercises the new list filter, runs the retry, asserts the row migrates from \`failed\`\`pending\`, the audit row is written, 404 on bad id, 400 on bad status filter.`,
``,
`**Upgrade path.** v0.1.0:42 → v0.1.0:43 is a straight drop-in. No new migrations, no schema changes, no behavior changes for buyers. Operators gain the new \`/v1/admin/webhook-deliveries\` endpoints; the admin SPA doesn't yet surface them but \`curl\` + an admin API key works today.`,
``,
`Alpha-iteration revision 42 of v0.1.0 — Test infrastructure expansion. No daemon-behaviour changes for operators; this release ships safety nets for future development. Skip if v0.1.0:41 is running cleanly — the next release with new functionality will roll forward whatever shipped here.`,
``,
`**API integration tests.** New \`tests/api.rs\` with seven integration tests that drive real HTTP requests through the daemon's \`axum::Router\` against a real SQLite tempfile. Covers: health endpoint, admin auth (401 vs 403 paths), admin product creation (full happy path: auth → handler → DB → audit log → response), license validation (parse-fail surface plus full crypto round-trip), free-tier purchase with inline license issuance, and BTCPay webhook idempotency (re-delivered settle webhooks MUST NOT duplicate the license — the production-correctness invariant we most needed to lock down).`,
``,
`**Library refactor.** Adds \`src/lib.rs\` that re-exports the daemon's modules so integration tests under \`tests/\` can drive \`AppState\` + \`Router\` directly. Production binary at \`src/main.rs\` is now a thin wrapper that imports from the library. No runtime behaviour change — same module sources, same compiled binary modulo build metadata.`,
``,
`**MockPaymentProvider.** A test-only \`PaymentProvider\` impl that bypasses HMAC signature verification and parses test-supplied JSON bodies into \`ProviderWebhookEvent\` variants. Used by the webhook test to drive deterministic settle / expire / invalid events without a real BTCPay round-trip. Lives entirely in \`tests/api.rs\` — never compiled into the production binary.`,
``,
`**Test count.** 9 unit + 4 migration + 7 API = 20 tests, up from 13 in v0.1.0:41. \`cargo test\` exercises the full set in under a second.`,
``,
`**KEYSAT_INTEGRATION.md restored.** v0.1.0:41's release notes erroneously claimed the file was "removed from this repo" — that was a misread of intent. The 1378-line integration guide is back at the daemon repo's root where integrators (and LLMs running fresh against a target codebase) expect to find it via \`https://raw.githubusercontent.com/keysat-xyz/keysat/main/KEYSAT_INTEGRATION.md\`. The 12-line stub at the parent licensing/ folder was removed since it referred to a "moved" arrangement that no longer applies.`,
``,
`**Known limitation: paid-purchase test deferred.** The HTTP \`/v1/purchase\` endpoint's paid path still uses the legacy \`state.btcpay_client()\` compat accessor (which downcasts the active provider specifically to the concrete \`BtcpayProvider\` type) rather than the abstract \`PaymentProvider\` trait. A \`MockPaymentProvider\` can't satisfy that downcast. Migrating \`purchase::start\` to \`state.payment_provider().await?.create_invoice(...)\` is a small refactor on the v0.3 backlog; once it lands, a paid-purchase integration test slots right in. The webhook handler IS already on the trait surface, which is why the webhook idempotency test works.`,
``,
`**Upgrade path.** v0.1.0:41 → v0.1.0:42 is a straight drop-in. No new migrations (the migration set still ends at 0009). No schema changes. No on-disk format changes. If you hit a checksum-mismatch error on boot, you're upgrading from a version older than :41 — see :41's release notes for the recovery command.`,
``,
`Alpha-iteration revision 41 of v0.1.0 — Second hotfix to migration 0009. v40 passed on a clean install but crashed on any database with existing rows in discount_redemptions. Also lands the migration test infrastructure that would have caught this before it shipped.`,
``,
`**The bug.** v40 rebuilt only the discount_codes table (using \`PRAGMA defer_foreign_keys = 1\` to avoid intra-transaction FK errors). On a clean install that works. On any install with even one row in discount_redemptions — the child table whose foreign key points back into discount_codes — it doesn't. SQLite's deferred FK check fires at COMMIT, sees the dropped parent's row-deletion bookkeeping as unsatisfied (regardless of whether the rebuilt table contains the same IDs), and rolls the whole transaction back with "FOREIGN KEY constraint failed (787)". Daemon kept restart-looping; API never came up.`,
``,
`**Why defer_foreign_keys wasn't enough.** When you DROP a table with inbound foreign keys from another rowset, SQLite's commit-time check enumerates those inbound rows and verifies they reference valid parents. The rebuild renames discount_codes_new back to discount_codes — same name, same row IDs — but SQLite's FK bookkeeping had tracked "the original discount_codes was dropped," and the deferred check sees that as a violation. defer_foreign_keys postpones the firing, but doesn't reconcile the bookkeeping.`,
``,
`**The fix.** Rebuild discount_redemptions inside the same transaction so its FK is freshly bound to the new discount_codes:`,
``,
`1. Heal pre-existing orphan FKs in discount_codes — NULL-out applies_to_product_id / applies_to_policy_id rows pointing at deleted parents.`,
`2. Delete orphan redemptions (rows whose code_id no longer exists).`,
`3. Stash discount_redemptions to a TEMP table, then DROP it — eliminates the inbound FK chain.`,
`4. Rebuild discount_codes with the new CHECK constraint that includes 'set_price'.`,
`5. Recreate discount_redemptions and restore data from the stash.`,
``,
`The COMMIT-time FK check now passes because both tables are clean and consistent, with FK references freshly bound at table-creation time rather than carried over from before the drop.`,
``,
`**Migration is idempotent.** Re-running 0009 against an already-migrated database produces the same end state. Operators who hit the v40 failure and worked around it (manual deletion of redemptions, then reboot) will see a checksum mismatch on this update — sqlx records each migration's content hash and v41's 0009 differs from v40's. Recovery is one command on the StartOS service shell:`,
``,
`\`\`\``,
`sqlite3 /data/keysat.db "DELETE FROM _sqlx_migrations WHERE version = 9;"`,
`\`\`\``,
``,
`Then restart the service. The new (idempotent) 0009 re-applies cleanly. No data loss — the migration is a structural rebuild, not a data change.`,
``,
`**Migration regression tests.** Both v39 and v40 shipped because no test exercised migrations against a populated database — every test started from an empty DB. v41 lands \`licensing-service/tests/migrations.rs\` with four integration tests that boot a real SQLite, seed realistic fixtures (products, policies, invoices, licenses, machines, discount codes, redemptions, webhooks, tip attempts), and apply migrations against the populated state. The regression test fails with the exact error 787 against the v40 migration; future migrations get the same scrutiny automatically. \`cargo test\` now reports 13 tests, up from 9.`,
``,
`**No data loss in v40's failure.** sqlx rolled back the wrapping transaction; the discount_codes / discount_redemptions tables are unchanged from v38 state.`,
``,
`Alpha-iteration revision 40 of v0.1.0 — Hotfix: migration 0009 in v0.1.0:39 was malformed and put the daemon in a startup-restart loop. This release is the corrected migration; install it to recover.`,
``,
`**The bug.** Migration 0009 in :39 did its own \`BEGIN TRANSACTION\` / \`COMMIT\` and a \`PRAGMA foreign_keys = OFF\`. sqlx-migrate already wraps each .sql file in a transaction, and SQLite doesn't allow nested transactions — so the inner BEGIN failed, sqlx rolled back the wrapping txn, the migration was never recorded as applied, and the daemon panicked on every boot. StartOS showed "Running" but the API was unreachable because the process kept exiting before binding port 8080.`,
``,
`**The fix.** Removed the redundant BEGIN/COMMIT (sqlx provides them). Replaced \`PRAGMA foreign_keys = OFF\` with \`PRAGMA defer_foreign_keys = 1\`, which is transaction-local — it postpones FK checks until COMMIT time without trying to disable them globally inside a transaction. Since the rebuild preserves all row IDs, the FK check at COMMIT passes cleanly.`,
``,
`**No data loss in :39's failure.** sqlx rolled back the wrapping transaction, so the discount_codes table is unchanged from :38. The new migration 0009 will apply cleanly to your existing database when you install :40.`,
``,
`Alpha-iteration revision 39 of v0.1.0 — Edit-able products and policies, license counts in the admin tables, and a critical migration fix.`,
``,
`**Migration 0009 — fix the discount-codes CHECK constraint to allow 'set_price'.** The 'set_price' kind was added at the daemon layer in v0.1.0:26, but the original 0004 migration's CHECK constraint only listed 'percent' / 'fixed_sats' / 'free_license'. SQLite was rejecting any insert with 'set_price' as "CHECK constraint failed". Migration 0009 rebuilds the discount_codes table with the four-kind CHECK; SQLite doesn't support ALTER TABLE DROP CONSTRAINT so the rebuild is the right path. All existing rows are preserved.`,
``,
`**Edit products from the admin UI.** New endpoint PATCH /v1/admin/products/:id and a corresponding Edit modal on the Products tab. Mutable: name, description, price_sats. Slug is intentionally not editable (it's part of the public buy URL — changing it breaks bookmarks; operators should disable + create a new product to rename).`,
``,
`**Edit policies from the admin UI.** New endpoint PATCH /v1/admin/policies/:id and a corresponding Edit modal on the Policies tab. Mutable: name, tier description, price override, duration (preset or custom), grace period, max devices, trial flag, entitlements, "Most popular" highlight. Slug + product + tip config are not editable here (tip has its own dedicated PATCH with separate validation rules).`,
``,
`**License counts in the Products and Policies tables.** New endpoint GET /v1/admin/licenses/counts returns license counts grouped by product_id and policy_id. The Products tab gains a Licenses column; the Policies tab gains the same. One COUNT-by-group query per fetch; cheap.`,
``,
`**On the discount-code-per-product question:** the existing create-code form already has a "Restrict to product slug (optional)" field — set it to scope the code to one product. Codes left unrestricted (slug field blank) work for any product. No change needed.`,
``,
`Alpha-iteration revision 38 of v0.1.0 — Force-delete for products and policies, exposed in the admin UI. Lets operators wipe test data they've accumulated against a product (or policy) — including issued licenses and invoices — without dropping to the SQLite shell.`,
``,
`**Backend.** \`DELETE /v1/admin/products/:id\` and \`DELETE /v1/admin/policies/:id\` now accept an optional \`?force=true\` query param.`,
` - Without \`force\` (the safe default): refuses with 409 if any invoice or license references the product / policy. Same behavior as :33.`,
` - With \`?force=true\`: cascades through the dependency tree in a single transaction — machines → discount redemptions → licenses → invoices → policies / codes → product. Audit log records the cascade counts (cascaded_licenses, cascaded_invoices, etc.) for forensic backtracking. Audit action is "product.force_delete" / "policy.force_delete" so the destructive variant is searchable.`,
``,
`**Admin UI.** Both Delete buttons now flow through a smart \`safeOrForceDelete\` helper:`,
` - First click → tries the safe DELETE. If it succeeds, done.`,
` - If the server returns 409 → opens a force-delete modal showing the original conflict message + a "type the slug to confirm" GitHub-style input. The "Force delete (irreversible)" button stays disabled until the typed slug matches exactly.`,
` - On confirm → POSTs with \`?force=true\`, then shows a brief toast summarizing what got cascaded ("product 'foo' force-deleted — also wiped: 3 license(s), 5 invoice(s)").`,
``,
`Type-the-slug confirmation prevents accidental nukes — single-click can never wipe customer history. Designed for the operator-tinkering use case where you've issued test licenses against a product you want to delete cleanly.`,
``,
`**Why expose this in the UI.** The same effect was previously achievable only via direct SQL inside the container (sqlite3 /data/keysat.db). Operators tinkering with new products + policies hit this constantly during pre-launch testing, and dropping into the container for what's logically a UI operation is hostile. This puts the same capability behind a sensible-friction confirmation dialog, with proper audit logging that the SQL path skipped.`,
``,
`Alpha-iteration revision 37 of v0.1.0 — Critical hotfix: the tier picker on /buy/<slug> was completely broken on every page load (TDZ error in the on-load \`selectTier(selectedPolicy)\` sync I added in :32). Symptom: clicking a tier card didn't update the price card; clicking Pay with Bitcoin caused a page reload instead of submitting; submit handler never attached because the IIFE threw before it ran.`,
``,
`Root cause: \`let appliedCode\` was declared after the on-load selectTier call, but selectTier's body reads \`appliedCode\` (\`if (appliedCode) { ... }\`). The on-load call hit appliedCode in its temporal-dead-zone, ReferenceError thrown, IIFE aborted, every subsequent handler attachment skipped.`,
``,
`Fix: hoisted \`let appliedCode = null;\` to the top of the IIFE alongside the other state variables. One line move; no behavior change beyond "the tier picker actually works again."`,
``,
`Why this slipped through: the on-load selectTier call shipped in :32 alongside a bunch of other changes; the bug only manifests when a tier is server-pre-selected, which is the common case but wasn't the path I exercised in the post-:32 sanity walkthrough.`,
``,
`Alpha-iteration revision 36 of v0.1.0 — Three test-driven UX fixes from the v0.1.0:35 dogfood walk-through.`,
``,
`**Tier-cap 402 is now an actionable modal**, not a text alert with the URL pasted into the message string. Server-side: AppError::PaymentRequired now carries \`{message, upgrade_url}\` separately and emits a structured 402 body. Client-side: api() throws errors annotated with \`status\` and \`body\`; a new handleTierCap() helper renders a cream/gold modal with a real "Get Pro license →" button when the response is a 402 with an upgrade_url. Falls back gracefully to the existing inline status pill / alert for non-tier-cap errors.`,
``,
`**Product delete is less restrictive.** Pre-:36 it refused if any policy referenced the product. That blocked the legitimate "I made setup mistakes and want to clean up" flow. Now: refuses only if INVOICES or LICENSES exist (real customer history). Policies and product-scoped discount codes get cascade-deleted in a single transaction since they're templates with no audit-trail value on their own. Audit log records the cascade counts for traceability. The policy-delete safety pattern is unchanged (still refuses on invoices+licenses).`,
``,
`**Manual license issuance from the admin UI.** The Licenses tab gains a "Manually issue a license" disclosure with a clean form: Product + Policy dropdowns (policies auto-filter when product changes), optional buyer email, optional internal note. Submit POSTs to the existing /v1/admin/licenses endpoint and shows the resulting signed key in a "Save it now" modal with a Copy button. Useful for self-issuing a Pro license to dogfood, comp licenses for press / partners / friends, or paper-licensing flows that don't go through BTCPay.`,
``,
`Alpha-iteration revision 35 of v0.1.0 — Fix the "Embed your public key" tip card actually showing the key. The :32 endpoint returns \`public_key_pem\` but the SPA was reading a stale \`public_key_b64\` field name from the very first scaffold of the admin UI. Renamed reading-side to accept either shape. Preview now strips the PEM BEGIN/END headers so the 12+12-char preview shows the actual key bytes instead of "-----BEGIN PUB…BLIC KEY-----". Copy button still copies the full PEM verbatim, ready to paste into source.`,
``,
`Alpha-iteration revision 34 of v0.1.0 — Display name on the StartOS services list reads "Keysat Licensing" instead of just "Keysat". Distinguishes the package from anything else in the future Keysat product family. Package id remains \`keysat\` (no migration); only the human-facing title changed.`,
``,
`Also bundled in this build (carried over from :33's Dockerfile change which was applied after some folks had already built :33): \`sqlite3\` is now in the runtime container, so \`start-cli package attach keysat\` operators have an SQL shell on hand for occasional admin tasks.`,
``,
`Alpha-iteration revision 33 of v0.1.0 — Keysat dogfoods its own tier model. Creator / Pro / Patron are now real, with caps enforced server-side and a persistent upgrade banner in the admin sidebar.`,
``,
`**Tier model.** Three tiers, all derivable from entitlements on the daemon's self-license:`,
` - **Creator** — entitlements: ["self_host"]. Caps: 5 products, 5 policies/product, 5 active discount codes. BTCPay only, one-time purchases. Sold for ~21,000 sats; also distributable via free codes for hobbyists.`,
` - **Pro** — entitlements: ["self_host", "unlimited_products", "unlimited_policies", "unlimited_codes", "recurring_billing", "card_payments", "team_seats"]. Unlimited everything, unlocks Zaprite + recurring billing when those ship in v0.3.`,
` - **Patron** — Pro + ["patron"]. Same feature surface, plus a Patron badge. Voluntary upsell for funding development.`,
` - **Unlicensed** — same caps as Creator. Operators can install and use Keysat without paying us anything; the upgrade pull is organic when they need more capacity.`,
``,
`**Server-side caps enforced** in /v1/admin/products, /v1/admin/policies, /v1/admin/discount-codes create handlers. Returning HTTP 402 Payment Required with a clear message and an \`upgrade_url\` pointing at the master Keysat's buy page. Caps fire at create-time only — operators above the cap aren't retroactively kicked off.`,
``,
`**Persistent upgrade banner** in the admin sidebar. Always visible (regardless of whether you're at a cap). Shows current tier label, contextual message, and the next-tier CTA. Patron operators see "Thank you for funding development" instead of an upgrade pitch. Pro operators see the Patron CTA. Creator/Unlicensed see the Pro CTA with a usage line ("Currently using 3/5 products"). Backed by a new admin endpoint GET /v1/admin/tier returning {tier, entitlements, usage, caps, upgrade_url}.`,
``,
`**Delete buttons on Products and Policies.** Same safety pattern as the existing discount-code Delete: hard-delete refused with 409 if any references exist (policies/invoices/licenses for a product; invoices/licenses for a policy). Operator should disable / hide instead in that case. Audit-logged.`,
``,
`**Pricing page** at keysat-docs/pricing.html — Creator / Pro / Patron tier comparison cards, what the caps count, how to switch tiers, what's coming in v0.3.`,
``,
`**Test-data wipe doc** at RESET_TEST_DATA.md (root of the workspace) — one-liner SQL for clearing pre-launch test data on a master Keysat that's accumulated stale products/policies/licenses during testing.`,
``,
`**No DB schema changes** — caps are enforced via existing entitlements field on the License/Policy models. Migrations 00010008 unchanged.`,
``,
`Alpha-iteration revision 32 of v0.1.0 — Three real bugs the first end-to-end tier test surfaced.`,
``,
`**Free-tier checkout no longer goes through BTCPay.** Before: clicking Pay on the Free tier created a 0-sat (well, 1-sat — BTCPay floor) invoice and dropped the buyer on a confusing "amount paid: 0 BTC" receipt. They had to click "Return to Keysat" to actually get the license. Now: when /v1/purchase computes a final price of 0 (free tier price_override=0, OR a paid tier with 100%-off), the daemon synthesizes a settled invoice locally, issues the license inline, and returns the signed key in the response body. The buy page detects the inline-license response and renders the license card directly — no BTCPay roundtrip, no fake receipt, no extra clicks. The button label also updates correctly: clicking a Free tier on the picker flips the CTA to "Redeem license" and the price card to "FREE", so the buyer sees the right state before submitting.`,
``,
`**Public key now resolves at the documented endpoint.** The admin Overview's "Embed your public key" tip was showing "unavailable" because the SPA fetches /v1/issuer/public-key but no handler was wired. Added the GET endpoint (no auth required — public keys are by definition public). The same endpoint will be useful to SDK consumers fetching the operator's signing key dynamically. Returns \`{public_key_pem, key_algorithm, key_format_version}\`.`,
``,
`**Admin Licenses table now shows entitlements + policy.** Two new columns:`,
` - **Policy**: the policy slug under which the license was issued (e.g. "free", "pro", "patron"). Hover for the policy's display name.`,
` - **Entitlements**: small mono-style chips for each entitlement on the license (e.g. \`self_host\`, \`recurring_billing\`).`,
`The product column now shows the product slug instead of a UUID prefix. Backed by a server-side enrichment in /v1/admin/licenses/search that joins to policies + products in two small queries — same response shape, just with extra fields.`,
``,
`**On the BTCPay "Return to Keysat" button label** (you asked): yes, it's customizable, but from BTCPay's side, not Keysat's. In your BTCPay → Store Settings → Checkout Appearance, there's a "Custom checkout CSS / store branding" area where you can tweak the button text and colors. The label is rendered by BTCPay's invoice frontend, not by Keysat. Recommended: change "Return to Keysat" to "View license" or "Get license" — same redirect, friendlier copy.`,
``,
`Alpha-iteration revision 31 of v0.1.0 — Tip-suggestion copy uses keysat@primal.net directly instead of the brand-aliased tip@keysat.xyz. Avoids the LNURL-pay static-proxy setup until we want a branded address.`,
``,
`One-line text change in the policy create form's tip-recipient hint and example. No behavior change otherwise — operators can paste any Lightning Address.`,
``,
`Alpha-iteration revision 30 of v0.1.0 — Price-per-tier on the policy form. Operators can set Free=0, Pro=250000, Patron=500000 etc. directly without curl gymnastics.`,
``,
`New "Price (sats)" field on the policy create form. Pre-fills with the chosen product's base price (the dropdown shows each product's price inline so the operator doesn't have to remember). Picking a different product re-prefills, unless the operator has already edited the value away from the prefill — in which case their edit is preserved (no clobbering).`,
``,
`Wire path: form → JSON body's \`price_sats_override\` → existing /v1/admin/policies POST → existing \`price_sats_override\` column on policies. The buy-page tier picker reads this for its per-card pricing. \`price_sats_override = 0\` works as a free tier (the buyer is never charged).`,
``,
`No backend changes — the API has accepted \`price_sats_override\` from day one; we just weren't exposing it on the form.`,
``,
`Alpha-iteration revision 29 of v0.1.0 — policy-create form rebuilt to remove the JSON-foot-guns. No more "do I type the brackets?" moments.`,
``,
`**Form-level changes** to the Policies → Create-a-new-policy disclosure in the admin SPA:`,
` - **Duration** is now a preset dropdown (Perpetual / 7d / 30d / 90d / 6mo / 1y / 2y / Custom) instead of a raw seconds input. Custom drops back to a seconds field for power users; otherwise the operator never sees the number 31536000 again.`,
` - **Grace period** moved to days (was seconds). The form does the *86400 conversion silently.`,
` - **Tier description** is now a dedicated text field. It writes to \`metadata.description\` under the hood, which the buy-page tier picker reads. Operators never see the metadata JSON.`,
` - **Mark as "Most popular"** is now a checkbox. Writes to \`metadata.highlight\` so the corresponding tier card gets a gold "Most popular" pill on /buy/<product>.`,
` - **Entitlements** is now a textarea (was a single-line input). Operators can write one entitlement per line OR comma-separated. The form is also defensive: it strips any \`[\`, \`]\`, \`"\`, \`'\` characters in case someone pastes a JSON-style array. No more "did I need quotes?".`,
` - **Max machines** relabeled "Max devices (0 = unlimited)" — clearer than "machines".`,
` - Inline help text on every field.`,
``,
`OpenSats Lightning address corrected to \`opensats@npub.cash\` (was the older nostrplebs.com address) in the tip-recipient suggestions.`,
``,
`**No backend changes.** The policies API has always accepted a \`metadata\` JSON object; we just weren't exposing the right fields in the form. Existing policies continue to work; their metadata is preserved on read/write.`,
``,
`Why this matters: the buy-page tier picker (shipped in :27) needs descriptions and highlighting to look right. Until :29 the only way to set those was to know the metadata schema and craft a JSON object. Now there's a checkbox and a text field. Closer to the bar where a non-developer operator can ship a paid product without reading source.`,
``,
`Alpha-iteration revision 28 of v0.1.0 — password-based admin auth, BTCPay revenue stats on the Overview, and a positioning update on the Keysat website.`,
``,
`**Password-based admin web UI (replaces API-key paste).** New StartOS action **General → Set web UI password** lets the operator define an argon2id-hashed password (12-char minimum). After setting it, the admin login page shows a password field instead of asking for the API key. Sign-in mints a 24-hour HttpOnly+Secure+SameSite=Strict session cookie; sliding renewal on every authenticated request. The API key continues to work for automation and is still the fallback for the very first login (before a password has been set). Brute-force protection: per-IP token bucket on /admin/login (5-attempt burst + 1 token per 3 minutes). Rotating the password invalidates all active sessions.`,
``,
`Implementation: cookie sessions ride on top of the existing API-key require_admin guard via a small axum middleware (\`session_to_bearer\`) that injects the API key as Authorization when a valid cookie is present. Every existing admin handler keeps working unchanged. Audit-log limitation: cookie-authenticated calls show the API key's sha256 as the actor. IP / user-agent on the same row distinguish sessions in practice; per-session actor identity is a v0.2 follow-up.`,
``,
`**Migrations:** \`0008_web_sessions.sql\` adds a \`sessions\` table (additive). \`web_ui_password_hash\` lives in the existing settings table.`,
``,
`**New crate:** \`argon2 = "0.5"\` (PHC-recommended pure-Rust hashing). Adds ~30 KB to the binary.`,
``,
`**New endpoints:**`,
` - POST /admin/login (public; password → cookie)`,
` - POST /admin/logout (clears cookie + deletes session row)`,
` - GET /admin/login/status (public; \`{has_password, logged_in}\`)`,
` - POST /v1/admin/web-password (admin-only; sets/rotates the password hash)`,
``,
`**Background task:** hourly session reaper drops expired rows.`,
``,
`**BTCPay revenue stats on the Overview.** New stat card "Revenue (lifetime)" plus a four-cell breakdown (lifetime / 30d / 7d / 24h) below the existing stats row. Backed by a new endpoint \`GET /v1/admin/revenue/summary\` that sums \`amount_sats\` across settled invoices in the local DB. Free-license redemptions have amount=0 and don't contribute. We deliberately don't call the BTCPay API for this — every invoice we created is in our DB with status + amount, so a local SUM is faster and works even when BTCPay is down. (If we ever want refunds / fees / Lightning-vs-onchain breakdown, that's when we'd add a BTCPay roundtrip.)`,
``,
`**Landing-page positioning update** (separate repo, keysat-xyz-landing). New paragraph in the hero: "Keysat empowers independent software creators to monetize any software they choose to sell — fully open source, free/paid versions, or fully closed source. The licensing layer is agnostic to your decision." Distinguishes Keysat from open-source-only / SaaS-required licensing services and makes explicit that the operator owns the model decision.`,
``,
`Alpha-iteration revision 27 of v0.1.0 — tiered pricing on the buy page. Operators with multiple policies now see a side-by-side tier picker on /buy/<slug>; buyers pick a tier explicitly; the chosen policy round-trips through purchase → BTCPay invoice → settlement webhook → license issuance.`,
``,
`**The picker.** When a product has 2+ active+public policies, /buy/<slug> renders a card grid above the existing form: each card shows tier name, price, duration, key entitlements (as bullets), and a Select button. Selecting a tier highlights its card with a gold border + ring shadow, updates the price card below the picker with that tier's price, and flips the form's submit to use that tier. When the product has 0 or 1 public policies, the buy page renders exactly as before — no UX change.`,
``,
`**Pre-selection logic.** \`?policy=<slug>\` deep-link wins (lets operators link buyers to a specific tier from marketing). Otherwise, any policy with \`metadata.highlight = true\` is pre-selected (and gets a "Most popular" gold pill). Otherwise, the cheapest tier is selected. Buyers can always change their selection before submitting.`,
``,
`**Policy "public" flag.** New \`public\` boolean column on the \`policies\` table (migration 0007, additive, defaults to 1 for existing rows). Admin can hide internal policies — Comp / press / internal team-seat templates — from the public buy page while still issuing under them via /v1/admin/licenses. Admin SPA gains a Show/Hide button on each policy row and a "On buy page" column.`,
``,
`**New endpoint:** \`GET /v1/products/:slug/policies\` (public, no auth). Returns the product (slug, name, description, base price) and an array of active+public policies with their buyer-facing fields (slug, name, description, price_sats, duration_seconds, max_machines, is_trial, entitlements, highlighted). Internal fields (id, tip recipient, raw metadata) are deliberately omitted.`,
``,
`**New invoice column:** \`policy_id\` (nullable, FK to policies). Stores the buyer's chosen tier on the invoice so issue_license_for_invoice can use it as the template. Pre-:27 invoices fall back to the legacy "default policy" pick (slug='default' or first active) — no breaking change.`,
``,
`**Updated APIs:**`,
` - POST /v1/purchase: accepts optional \`policy_slug\`. When set, the policy's \`price_sats_override\` becomes the base price (if defined), and the policy is persisted on the invoice. Validates the chosen tier is active+public; admins issuing comps stay on /v1/admin/licenses.`,
` - POST /v1/redeem: accepts optional \`policy_slug\`, same semantics. Works for free-license codes that should be issued under a specific tier.`,
` - GET /v1/discount-codes/preview: accepts optional \`policy_slug\` query param. Discount math is computed against the chosen tier's effective price; codes restricted to a different policy return \`{valid: false, reason: "wrong_tier"}\`.`,
` - POST /v1/admin/policies/:id/public: new admin endpoint, audit-logged as policy.set_public. Toggles the public flag.`,
``,
`**Code-applied-to-policy enforcement.** Discount codes have an \`applies_to_policy_id\` column from migration 0004; pre-:27 it was informational only. Now it's enforced in /v1/purchase, /v1/redeem, and /v1/discount-codes/preview: a code restricted to a specific tier is rejected on any other tier with a clear error message.`,
``,
`**Buy-page tier metadata conventions** (no schema change required):`,
` - \`metadata.description\` (string): short blurb shown on the tier card. ~1 sentence works best.`,
` - \`metadata.highlight\` (bool): true → "Most popular" gold pill + pre-selection.`,
`Both are optional. Existing policies without these keys render fine, just plainer.`,
``,
`**Database changes:** migration 0007_tiered_pricing.sql, additive only:`,
` - ALTER TABLE policies ADD COLUMN public INTEGER NOT NULL DEFAULT 1;`,
` - ALTER TABLE invoices ADD COLUMN policy_id TEXT REFERENCES policies(id);`,
` - CREATE INDEX idx_policies_public ON policies(public);`,
``,
`**Net effect.** Operators can run a real free + paid tier model from a single buy URL. Keysat itself can list keysat-free / keysat-pro / keysat-patron tiers from one /buy/keysat URL once the corresponding policies are created on the master Keysat. Foundation for the v0.3 entitlement-gating of recurring billing + Zaprite (Pro-only features).`,
``,
`Alpha-iteration revision 26 of v0.1.0 — anonymous-friendly checkout, editable discount codes, and a new "set flat price" code kind.`,
``,
`**Anonymous-friendly checkout.** Email is now genuinely optional on /buy/<slug>. Reworded label to "Email (optional)" and the hint to: "Useful only if you want a buyer reference for lost-key recovery. Skip it to pay anonymously — your license key is shown directly on this site either way." The form no longer enforces \`required\`. The success card now displays the invoice id ("Reference for support: <id>") in place of the email-based reference, so an anonymous buyer who needs help still has a concrete reference to give the seller.`,
``,
`**Edit discount codes.** New endpoint: PATCH /v1/admin/discount-codes/:id. Mutable fields are amount, max_uses, expires_at, description, referrer_label. Code string, kind, and product/policy scope are deliberately NOT editable — changing those would silently invalidate links already in circulation; operators should disable + create a new code instead. max_uses cannot be set below the current used_count. Audit-logged as discount_code.update.`,
``,
`Admin UI gains an Edit button next to Disable/Delete on each code row. Clicking opens an inline edit panel above the table pre-populated with the code's current values; Save PATCHes and reloads, Cancel closes without changes.`,
``,
`**New "set flat price" discount kind.** \`kind: 'set_price'\` lets an operator say "with this code, the buyer pays exactly N sats" regardless of base price. Useful for promo flat-rates ("everyone gets it for 5000 sats this week") and for regional pricing. If amount is greater than or equal to base, the code provides no benefit (final price = base). Wired through the same three places the other kinds live: purchase math, free-redeem path doesn't apply (only \`free_license\` skips BTCPay), and the public preview endpoint shows "Flat price applied: N sats." Validation: amount must be > 0 at create time. Existing kinds (\`percent\`, \`fixed_sats\`, \`free_license\`) unchanged.`,
``,
`Admin UI: the create-code Kind dropdown gains "Set flat price (in sats)". The Amount field's hint updates live as the operator changes Kind, so the meaning of the number is always obvious. Codes table shows "5,000 sats flat" for set_price entries (vs "5,000 sats off" for fixed_sats).`,
``,
`No DB schema changes since :25 — all changes are at the application layer.`,
``,
`Alpha-iteration revision 25 of v0.1.0 — admin Licenses tab actually shows licenses, plus thank-you page hardening and honest copy.`,
``,
`**Bug fix (high-impact UX bug):** the admin Licenses tab was rendering an empty search box with no auto-load, so issued licenses appeared invisible until the operator typed something into the search field. The backend already returns the 100 most-recent licenses when called with no filters; the UI just never called it. Tab now auto-loads recent licenses on open, with a Clear button to reset back to recent after a search. Empty-state copy is friendlier ("No licenses issued yet — once a buyer purchases or redeems, they appear here") instead of misleading.`,
``,
`**New endpoint:** GET /v1/admin/licenses/summary — returns aggregate counts {total, active, suspended, revoked, last_24h, last_7d}. Wires the Overview dashboard's "Active licenses" stat card to a real value (it was silently 404ing and showing ""). Cheap query, runs on every dashboard load.`,
``,
`**Buy-form copy honest fix:** the email-field hint claimed "we'll send your license key here after payment confirms" — but Keysat doesn't have SMTP delivery yet. Reworded to "Used as your buyer reference for support and lost-key recovery. Your license key is shown directly on this site after payment." Same change on the inline success card and the /thank-you success card. Email sending is a v0.2 build; until then the buy-page promise matches what actually happens. Email is still captured and stored on the license — just not actively sent.`,
``,
`**Thank-you page hardening for buyers who click "Return to Keysat" early:** BTCPay's processing screen has a Return button. Clicking it before the invoice settles lands the buyer on /thank-you?invoice_id=… while the license isn't yet issued. The polling loop:`,
` - Was 12 minutes hard-cap with a fixed 3s cadence — tight against Bitcoin block-time variance, especially on slow blocks.`,
` - Now: adaptive cadence (3s for first 2 min, 10s for 210 min, 30s for 1030 min) and a 30-min hard deadline. Saves bandwidth on the buyer's phone without missing slow blocks.`,
` - Improved waiting copy that explains what's happening and tells the buyer the URL is bookmark-friendly so they can close the tab and come back.`,
` - The pending card now displays the invoice id ("Reference for support: <id>") so a buyer who hits the deadline has something concrete to send the seller.`,
``,
`Net effect: buyers can safely click Return-to-Keysat early without missing the license. Operators see issued licenses in the admin without having to search. Buy-page promises now match implementation.`,
``,
`No DB schema changes since :24.`,
``,
`Alpha-iteration revision 24 of v0.1.0 — Apply-discount button on the buy page + delete discount codes from the admin UI.`,
``,
`Buy page (/buy/<slug>) — buyers can now click an "Apply" button next to the discount code input to preview the discount before committing. The price card updates with strikethrough on the original price, the new price, and a green tag showing the percent or sats off. If the code is a free_license type, the primary CTA flips from "Pay with Bitcoin" to "Redeem license" and skips the BTCPay path entirely on submit. Validation happens against a new public endpoint GET /v1/discount-codes/preview which checks existence/active/expiry/product/exhaustion and computes the discounted price WITHOUT consuming a redemption slot. Editing the code after Apply resets the price card.`,
``,
`Admin UI — discount codes table now has a Delete button next to Disable/Enable. Hard-deletes the code with a confirmation prompt. Backed by a new endpoint DELETE /v1/admin/discount-codes/:id that refuses with 409 Conflict if any redemptions reference the code (preserves the audit trail). Operators should keep using Disable for redeemed codes; Delete is for cleaning up codes that were created but never used.`,
``,
`New public endpoint: GET /v1/discount-codes/preview?code=…&product=… — used by the buy page Apply button. Returns {valid, code, kind, is_free, base_price_sats, discount_applied_sats, final_price_sats, amount_pct, message}. Same pricing math as /v1/purchase, kept in sync.`,
``,
`New admin endpoint: DELETE /v1/admin/discount-codes/:id — audit-logged as discount_code.delete; returns 409 Conflict with a clear message if the code has been redeemed.`,
``,
`Net effect: the buy page is now a single-form purchase flow that handles paid + free + discount-coded purchases without surprises, and the admin can prune mistakenly-created codes.`,
``,
`No DB schema changes since :23.`,
``,
`Alpha-iteration revision 23 of v0.1.0 — buyers actually receive their license after paying.`,
``,
`Three coordinated fixes:`,
`1. KEYSAT_PUBLIC_URL is now picked using pickPublicUrl (clearnet preferred) instead of pickBrowserUrl (mDNS preferred). The daemon's own public URL needs to resolve from random buyer browsers, not just the operator's LAN.`,
`2. purchase.rs now defaults BTCPay's redirect_url to {public_base_url}/thank-you?invoice_id=<internal-id> so BTCPay sends the buyer back to a Keysat page after payment. Internal invoice id is also used as the local row id (was previously a fresh UUID), so /v1/purchase/<internal_id> and /thank-you?invoice_id=<id> both resolve to the same row.`,
`3. /thank-you completely rewritten as a buyer-facing license-display page. Reads ?invoice_id from the URL, polls /v1/purchase/<id> every 3 seconds, renders the license in a certificate-style card with a Copy button when issued. Polls for up to 12 minutes before giving up. Falls back to a friendly error if the invoice id is missing/invalid.`,
``,
`Net effect: after paying via BTCPay, buyers land on a Keysat-branded thank-you page that auto-displays their license key as soon as the BTCPay webhook fires and the daemon issues the license. No StartOS dashboard required — this is a pure end-buyer flow.`,
``,
`Database change: repo::create_invoice now takes the invoice id as a parameter (was previously self-generated). Backwards-compatible at the schema level.`,
``,
`Alpha-iteration revision 22 of v0.1.0 — buy page auto-handles free-license discount codes.`,
``,
`Before: pasting a discount code of kind 'free_license' on /buy/<slug> still tried to create a BTCPay invoice for the post-discount sat amount, which BTCPay rejected with "amount below dust threshold" for tiny amounts. Buyers had to manually curl /v1/redeem to actually use free codes.`,
``,
`Now: when a code is provided, the buy page tries POST /v1/redeem first. If the code is free_license type, the daemon issues a license directly with no payment leg and the page renders the license key inline in a certificate-style success card with a Copy button. If the code is percent or fixed_sats type, /v1/redeem returns "this code requires payment" and the page falls through to the standard BTCPay purchase flow with the code applied. Real code errors (unknown, expired, wrong product) surface to the buyer cleanly.`,
``,
`Net effect: free-license codes now Just Work via the normal buyer UI. Useful for press, beta testers, partners, the early-100-users plan, etc.`,
``,
`Alpha-iteration revision 21 of v0.1.0 — actually fix the buyer-facing checkout URL.`,
``,
`Bug found via :20 diagnostic logs: BtcpayProvider::create_invoice (the trait method) had the rewrite logic, but purchase.rs uses the compat shim state.btcpay_client() which returns the raw BtcpayClient and bypasses the trait entirely. Result: the rewrite was never reached, and buyers always got the internal Docker hostname.`,
``,
`Fix: apply the same rewrite_to_public helper inline in purchase.rs after BtcpayClient::create_invoice returns. Same diagnostic log lines now fire from the purchase code path. Eventually purchase.rs (and reconcile.rs, tipping.rs) will migrate fully to the PaymentProvider trait — that's a v0.3 cleanup. For now the rewrite happens in both places so the urgent buyer-facing bug is fixed.`,
``,
`Operator action: install, then make a fresh purchase. The new log line "purchase: checkout URL rewritten for buyer" with original/rewritten URLs should appear, and the Pay-with-Bitcoin redirect should land on \`https://btcpay.<your-domain>/i/...\`.`,
``,
`Alpha-iteration revision 20 of v0.1.0 — diagnostic logging on the BTCPay checkout-URL rewrite path.`,
``,
`On startup the daemon now logs the resolved \`btcpay_url\`, \`btcpay_browser_url\`, and \`btcpay_public_url\` so it's clear what the wrapper handed in. On every checkout-URL rewrite, BtcpayProvider logs the original URL, the rewritten URL, and the public_base used. If public_base is None (no rewrite), it logs a loud warning explaining what to check.`,
``,
`Use these logs to diagnose any remaining "buyer gets the internal .startos URL" issue: tail Keysat logs, kick a purchase, look for "checkout URL rewritten" (good) or "checkout URL NOT rewritten" (misconfig — wrapper or env var problem).`,
``,
`No code-flow changes since :19; pure observability bump.`,
``,
`Alpha-iteration revision 19 of v0.1.0 — buyer-facing checkout URLs now use clearnet domain instead of mDNS.`,
``,
`:18 added a checkout-URL host rewrite, but used the same URL-picker as the operator OAuth redirect — which prefers mDNS/LAN URLs (good for the operator on the same LAN as the Start9, useless for buyers on the public internet). The rewrite produced URLs like \`https://immense-voyage.local:49347/i/...\` that random buyers couldn't resolve.`,
``,
`:19 splits the pickers. New \`pickPublicUrl\` prefers domain-named clearnet URLs (e.g. \`https://btcpay.your-domain.com\`) over IP/mDNS, used specifically for buyer-facing checkout URL rewrites. \`pickBrowserUrl\` (operator OAuth flow) keeps preferring LAN/mDNS — operator is local, faster path. New env var \`BTCPAY_PUBLIC_URL\` plumbs the public-preferred URL into the daemon, and the BtcpayProvider's host-rewrite uses it instead of \`BTCPAY_BROWSER_URL\`.`,
``,
`Operator action: install, then Disconnect → Connect BTCPay one more time to refresh the active provider with the new public URL. After that, /buy/<slug> should produce checkout URLs at your clearnet BTCPay domain (e.g. \`https://btcpay.keysat.xyz/i/...\`) which buyers can actually open.`,
``,
`Falls back to the old behaviour (BTCPAY_BROWSER_URL = mDNS) only if no clearnet URL is configured for BTCPay — useful for local-only testing but won't produce working URLs for real customers.`,
``,
`Alpha-iteration revision 18 of v0.1.0 — proper fix for BTCPay checkout URLs (revert :17, rewrite at the boundary instead).`,
``,
`:17 changed BTCPAY_URL to BTCPay's public StartTunnel URL for API calls. That broke the OAuth Connect flow because the Keysat container can't reliably reach the public URL from outside (StartOS egress routing). Reverted that.`,
``,
`Better fix: keep API calls on the internal \`btcpayserver.startos:23000\` hostname (fast, always reachable). Then in the BtcpayProvider's create_invoice path, rewrite the checkout URL's host (scheme + host + port) from the internal one to BTCPay's public URL before returning to the buyer. Path/query/fragment are preserved. Buyers now get a working public URL; daemon-to-daemon API calls stay internal.`,
``,
`Operator action: install this version, then run Disconnect BTCPay → Connect BTCPay once to refresh stored connection state. After that, /buy/<slug> purchases should produce a checkout URL like \`https://btcpay.<your-domain>/i/...\` instead of \`btcpayserver.startos:23000/i/...\`.`,
``,
`New cargo dep: \`url = "2"\` (already transitively present via reqwest; now declared directly for the host-rewrite helper).`,
``,
`Alpha-iteration revision 17 of v0.1.0 — fix BTCPay URL handed to daemon (checkout URLs were broken for buyers).`,
``,
`Bug: BTCPAY_URL was hard-coded to the internal Docker hostname \`btcpayserver.startos:23000\`. When Keysat created an invoice via BTCPay's API at that URL, BTCPay generated a checkout URL using the same internal hostname — and any buyer hitting that checkout URL got a "Server Not Found" error because \`.startos\` only resolves on the local Start9.`,
``,
`Fix: BTCPAY_URL now defaults to BTCPay's PUBLIC URL (the same URL used for browser redirects during the authorize flow). API calls cost a small out-and-back through StartTunnel per invoice — invoice creation is rare and the URL correctness wins. Falls back to the internal URL if the public URL hasn't been enumerated yet.`,
``,
`After installing this version, run Disconnect BTCPay → Connect BTCPay once to refresh the stored connection state, then test with a fresh /buy/<slug> purchase. The checkout URL should now be \`https://btcpay.<your-domain>/i/...\` instead of \`btcpayserver.startos:23000/i/...\`.`,
``,
`Alpha-iteration revision 16 of v0.1.0 — public buyer-facing purchase page.`,
``,
`New route: GET /buy/:slug. Server-renders a Keysat-branded HTML page for a given product slug — name, description, price-in-sats, optional email + discount code form, "Pay with Bitcoin" button. The button POSTs via JS to /v1/purchase, gets the BTCPay checkout URL, redirects the buyer there. After payment BTCPay returns them to /thank-you (existing handler).`,
``,
`Inlined navy/cream/gold styling matches the rest of the Keysat brand. Self-contained — no asset hosting required. 404 for inactive or missing slugs with a friendly explanation page.`,
``,
`Operator's "buy URL to share with customers" is now: https://<keysat-host>/buy/<product-slug>. Update marketing copy / install docs to point at this URL.`,
``,
`Alpha-iteration revision 15 of v0.1.0 — admin-only issuer-key import endpoint for master-Keysat bootstrap.`,
``,
`New endpoint: POST /v1/admin/import-issuer-key. Accepts a PEM-encoded Ed25519 private key in the request body, validates it, and upserts into the server_keys table replacing the auto-generated keypair. Refuses if any licenses have already been issued (safety guard against accidentally invalidating customer keys). Audit-logged. Restart the service after a successful import for the new keypair to take effect.`,
``,
`Why this isn't a StartOS Action: it'd clutter every operator's UI to serve a one-time setup for the single master operator. Documented in MASTER_KEYPAIR_PROCEDURE.md as the canonical bootstrap path. Curl during master-Keysat setup, never touched by the 95% of operators selling their own software.`,
``,
`No DB schema changes. No new dependencies.`,
``,
`Alpha-iteration revision 14 of v0.1.0 — Marketplace icon updated to the new Keysat brand mark (gold key on a navy-bordered certificate). Cosmetic only — no code or schema changes since :13.`,
``,
`Alpha-iteration revision 13 of v0.1.0 — PaymentProvider abstraction (Phase 1 of multi-provider work).`,
``,
`Refactor only — no user-visible behavior change. Sets up v0.3 to add Zaprite as a second payment provider alongside BTCPay without parallel code paths.`,
``,
`New module 'src/payment/' defines a PaymentProvider trait with create_invoice / get_invoice_status / validate_webhook / pay_lightning_invoice methods. BtcpayProvider is the first impl, wrapping the existing BtcpayClient and HMAC webhook secret. The webhook handler now dispatches through the trait — same BTCPay flow, but the abstraction is exercised end-to-end so we know the design holds before Zaprite arrives.`,
``,
`AppState replaces its 'btcpay' field with 'payment: Arc<RwLock<Option<Arc<dyn PaymentProvider>>>>'. Existing BTCPay-specific call sites (purchase, reconcile, tipping) unchanged; they go through compat accessors that downcast the trait object back to BtcpayProvider. Those compat accessors retire in v0.3 as the call sites migrate.`,
``,
`New cargo dep: async-trait (for object-safe async methods on the new trait).`,
``,
`No DB schema changes vs :12.`,
``,
`Earlier in the v0.1.0 line:`,
`:12 — Tip-recipient on policy + Support development footer link.`,
`:11 — Keysat-licenses-Keysat dogfooded; daemon embeds master pubkey, verifies /data/keysat-license.txt at boot; new "Activate Keysat license" + "Show license status" StartOS actions.`,
`:10 — admin web UI restyled in Keysat brand (navy/cream/gold).`,
`:9 — admin web UI made functional; Actions trimmed to setup-only.`,
`:8 — embedded admin SPA scaffolding (placeholder).`,
`:7 — operator-name live-reload; idempotent Connect BTCPay; Disconnect action; payment-method check.`,
`:6 — CSRF state encoded inside redirect URL.`,
`:5 — URL ranking applied to our own public URL.`,
`:4 — URL ranking by browser-reachability for BTCPay's URL.`,
`:3 — getAll() over BTCPay interfaces, filter by type='ui'.`,
`:2 — broader BTCPay URL filter for LAN-only setups.`,
`:1 — kebab-case action IDs; task severity 'important'; root in container; BTCPAY_BROWSER_URL plumbing.`,
`:0 — initial release.`,
].join('\n'),
migrations: {
up: async () => {},
down: async () => {},
},
})