3c054c65db
Lifts the "scope cannot be edited" rule for policies. Product scope remains read-only (moving a code between products has weird semantics for historical redemptions), but the tiers a code applies to can now be refined in-place via the Edit form's pill multi-picker. - repo::update_discount_code: new applies_to_policy_id param (Option<Option<String>>) alongside the existing applies_to_policy_ids multi field. Both update the right columns; caller passes a consistent pair so singular + JSON columns don't drift. - Admin PATCH endpoint: new optional `policy_slugs` field. Server resolves slugs against the code's existing product, then normalizes: - [] → both columns NULL (any policy on the product) - [one] → singular column set, JSON column cleared - [two+] → JSON column set, singular column cleared Sending no `policy_slugs` leaves scope alone (back-compat). - Edit form: pill multi-picker replaces the read-only Applies-to label. Pre-selected from the code's current allowed-policy set. Product label stays read-only above the picker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
387 lines
61 KiB
TypeScript
387 lines
61 KiB
TypeScript
// Draft of the v0.2.0 milestone version entry.
|
||
//
|
||
// NOT YET WIRED INTO `versions/index.ts` — this file sits ready to
|
||
// use when we cut v0.2.0:0 from the alpha-iteration line. To
|
||
// activate:
|
||
// 1. In `versions/index.ts`:
|
||
// import { v0_2_0 } from './v0.2.0'
|
||
// export const versions = VersionGraph.of({
|
||
// current: v0_2_0,
|
||
// other: [v0_1_0], // ← so installs on 0.1.0:N can upgrade
|
||
// })
|
||
// 2. Build the .s9pk (`make x86`).
|
||
// 3. Publish via `~/.keysat/publish.sh` (the version-changed gate
|
||
// will fire because `0.2.0:0` differs from the recorded
|
||
// `0.1.0:N`).
|
||
//
|
||
// Why this draft exists separately:
|
||
// - The cut is an irreversible release decision for already-installed
|
||
// operators (downgrade paths exist in StartOS but they're sticky).
|
||
// - Wiring it in changes how StartOS computes the upgrade dialog
|
||
// shown to operators on registry refresh — best to QA the
|
||
// release-notes content in this file before flipping the switch.
|
||
// - Lets us write the v0.2.0 release notes carefully and then ship
|
||
// them all at once, rather than amending mid-build.
|
||
//
|
||
// Version-string format reminder: ExVer is `<upstream>:<downstream>`.
|
||
// The `<upstream>` bump from 0.1.0 → 0.2.0 marks the milestone; the
|
||
// `:0` resets the downstream revision counter for the new line. The
|
||
// next routine wrapper update on the v0.2 line will be `0.2.0:1`,
|
||
// then `:2`, etc.
|
||
|
||
import { VersionInfo } from '@start9labs/start-sdk'
|
||
|
||
const RELEASE_NOTES = [
|
||
'Keysat v0.2.0 — first non-alpha milestone. Operator-visible: web admin SPA replaces the StartOS Actions tab for day-to-day work, buyer self-service recovery, opt-in community analytics, and the wire format now agrees byte-for-byte across five language SDKs (Rust, TypeScript, Python, Go, plus the daemon itself).',
|
||
'',
|
||
'**The web admin SPA is the headline.** Daily operator work — creating products, configuring policies and discount codes, searching licenses, suspending/revoking, inspecting machines, registering webhook endpoints, browsing the audit log — happens in the embedded dashboard at /admin/. The StartOS Actions tab is intentionally trimmed to setup-time operations only (Connect/Disconnect BTCPay, Set operator name, Set web UI password, Activate Keysat license, Show credentials). No more "wall of buttons" for everyday tasks.',
|
||
'',
|
||
'**Buyer self-service recovery.** A buyer who lost their license key can re-derive it themselves from (invoice_id, buyer_email) at /recover on the daemon\'s public URL. No support ticket, no operator involvement. Per-IP rate limited (10 req/min), generic-404 on mismatch (does not leak which side of the pair was wrong), audit-logged with the email\'s SHA-256 hash so the log doesn\'t store PII.',
|
||
'',
|
||
'**Webhook delivery DLQ.** The outbound-webhook delivery worker has always retried failed deliveries with exponential backoff up to 10 attempts; failed deliveries past that were silent dead-letters. v0.2 surfaces them: `GET /v1/admin/webhook-deliveries?status=failed` lists them, `POST /v1/admin/webhook-deliveries/:id/retry` re-queues. Surfaced in the SPA on the Webhooks page (defaults to the "Failed" filter so the problem case is what an operator sees first).',
|
||
'',
|
||
'**Opt-in community analytics.** Off by default. When enabled (Overview page in the admin UI), the daemon sends a daily anonymous heartbeat: install_uuid (random, not derived from operator identity), daemon version, tier label, and counts (products / active licenses / settled invoices) floored to the nearest 5 to prevent fingerprinting an operator by their exact license count. Uptime is bucketed (<1d / 1-7d / 1-4w / >4w). Operator name, public URL, store id, API keys, buyer email are NEVER sent — and the test suite asserts none of those strings appear in the heartbeat payload.',
|
||
'',
|
||
'**Five-language SDK parity.** The Go SDK (github.com/keysat-xyz/keysat-client-go) lands alongside this release. Stdlib only — no third-party Go dependencies. All five implementations of the LIC1 wire format (daemon, Rust SDK, TypeScript SDK, Python SDK, Go SDK) pass the same crosscheck vectors at tests/crosscheck/vector.json byte-for-byte across v1 legacy, v2 trial-with-entitlements, and v2 perpetual-unbound fixtures.',
|
||
'',
|
||
'**PaymentProvider trait abstraction.** Internally, the four daemon code paths that talked to BTCPay (purchase, webhook, reconcile, tipping) all now go through the abstract PaymentProvider trait. BTCPay-specific concerns (URL rewriting, status-string normalization, metadata enrichment, payment-hash extraction) live inside the BtcpayProvider impl. This unblocks Zaprite (v0.3) — its impl drops in cleanly without touching call sites.',
|
||
'',
|
||
'**Test coverage.** The daemon\'s automated test count grew from ~9 in alpha-iteration :24 to 32 in :47: 9 unit + 12 API integration + 4 SQL migration regression + 4 wire-format crosscheck + 3 webhook-worker integration. Plus the four Go SDK crosscheck tests in the separate Go repo.',
|
||
'',
|
||
'**Upgrade from v0.1.0:N.** Straight drop-in. No new SQLite migrations on the v0.2.0:0 cut itself (those landed individually during the alpha iteration). Existing licenses, invoices, products, policies, and discount codes are untouched. Web UI password, BTCPay connection, operator name, tip-recipient configuration all carry over.',
|
||
'',
|
||
'**What\'s next (v0.3).** Zaprite payment provider for card payments. Recurring subscriptions. In-place tier upgrades for end customers. Multi-currency pricing (USD + sats with auto-conversion at invoice creation).',
|
||
].join('\n')
|
||
|
||
// Routine wrapper-revision changelog. Newest first; each entry is
|
||
// what changed since the previous downstream-:N. The `:0` notes are
|
||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||
// append here.
|
||
const ROUTINE_NOTES = [
|
||
'0.2.0:22 — **Policy scope is now editable on existing discount codes.** Previously, the Edit form showed scope as a read-only "Applies to:" label and forced operators to disable + recreate any code whose tier scope needed adjusting. That rule existed to "avoid silently invalidating distributed links" — but the same argument applies to `amount`, `max_uses`, and `expires_at`, all of which are already editable. Inconsistent. So: policy scope joins them.',
|
||
'',
|
||
'**Edit form: pill multi-picker for policy scope.** The Edit modal now renders the same gold-on-navy pill picker as Create, pre-selected with the code\'s current allowed-policy set. Toggle pills to refine: 0 picked → "any policy on this product"; 1 picked → singular scope (writes the legacy column); 2+ picked → multi-policy scope (writes the JSON column).',
|
||
'',
|
||
'**Product scope: still read-only.** Moving a discount code from one product to another has weirder semantics for historical redemptions (a redeemed code now points at a product it never originally applied to), so the product field stays locked. To re-product a code: disable + recreate.',
|
||
'',
|
||
'**API.** `PATCH /v1/admin/discount-codes/<id>` now accepts an optional `policy_slugs: string[]` field. Server resolves slugs against the code\'s existing product, then normalizes: empty → both scope columns NULL, single → singular column populated + JSON column cleared, multi → JSON column populated + singular column cleared. Sending no `policy_slugs` field at all leaves scope alone (back-compat).',
|
||
'',
|
||
'**Test count: 87** (unchanged — same data model as v0.2.0:20, just exposed on the update path).',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:21 → v0.2.0:22 is a drop-in. No schema, no SDK breaking change.',
|
||
'',
|
||
'0.2.0:21 — **Wider buy page so 3-tier grids breathe.** The public /buy/<slug> page was capped at 560px, which packed three tier cards into a too-narrow column on desktop browsers. Bumped the outer container to 1040px so the tier picker matches the admin Policies page layout. The form, price card, and intro text below the tier picker remain centered at the 560px reading-width so the buy form doesn\'t look stretched. Mobile (≤480px) breakpoint unchanged. Topbar inner widened to match. UI-only; no API or schema change.',
|
||
'',
|
||
'0.2.0:20 — **Discount codes can apply to multiple policies, not just one.** Operator picks a subset (e.g. "Patron AND Pro but not Creator") on a single code instead of cloning the code under different names.',
|
||
'',
|
||
'**What changed.** Previously, a discount code\'s tier scope was a single policy (`applies_to_policy_id`) or "any policy on this product" / global. To offer the same discount across two of three tiers required creating two codes with distinct strings — operationally messy and harder for buyers. The form now has a tier multi-select pill picker: click tiers to toggle inclusion. 0 picked = "any policy on this product" (unchanged). 1 picked = single-policy scope (writes to the legacy column for clarity). 2+ picked = the code applies if and only if the chosen tier is in the picked set.',
|
||
'',
|
||
'**Migration 0018.** Additive: adds one nullable `applies_to_policy_ids_json` column to `discount_codes` for the multi-scope JSON array. Pre-existing codes have the column NULL and behave identically — the legacy singular column is still authoritative when the JSON column is empty.',
|
||
'',
|
||
'**Scope enforcement.** Both the public purchase endpoint and the admin "preview discount" endpoint now consult a unified `DiscountCode::allowed_policy_ids()` helper that returns the multi-policy list when non-empty or falls back to the legacy singular column. The featured-discount lookup also handles multi-policy: a featured code listing N policies surfaces correctly on the buy page for any of those tiers.',
|
||
'',
|
||
'**Edit form: scope still read-only.** Multi-policy scope is settable on creation and visible on the edit form (e.g. "Applies to: Keysat → Patron (patron), Pro (pro)") but, like all scope fields, isn\'t editable after the fact — operator disables + recreates to re-scope. Same constraint v0.2.0:17 introduced for the singular field; multi-policy follows the same rule to avoid silently invalidating distributed links.',
|
||
'',
|
||
'**SDK / API.** `POST /v1/admin/discount-codes` accepts an optional `policy_slugs: string[]` alongside the existing `policy_slug`. When both are present, `policy_slugs` wins. The list/get endpoints now include `applies_to_policy_ids: string[]` on every code (empty array when not multi-scoped). All other endpoints are unchanged; old SDKs that don\'t know about the field continue to work.',
|
||
'',
|
||
'**Test count: 87** (unchanged — scope logic is the same shape, just unifies over a Vec instead of a singleton).',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:19 → v0.2.0:20 applies migration 0018 (additive). Existing codes keep their existing scope and behavior. No SDK breaking change.',
|
||
'',
|
||
'0.2.0:19 — **Marketing-bullets position: above or below the entitlements.** Tiny operator-control add: pick where the free-form ✓ checkmark copy renders on each tier card.',
|
||
'',
|
||
'**The change.** Marketing bullets (`metadata.marketing_bullets`) have always rendered ABOVE the entitlement chips. That\'s usually right for "lifestyle" bullets like "Up to 5 products" / "BTCPay integration" — they sell the tier. But for tiers where the entitlements ARE the headline and the marketing bullets are caveats or fine-print, operators want them BELOW. New `metadata.marketing_bullets_position` field (`"above"` default, `"below"` opt-in) controls this per-policy. UI: small dropdown next to the bullets textarea on both create and edit forms. Renders consistently across the admin grid, the buy page, and the public `/v1/products/<slug>/policies` JSON (so SDK consumers stay in sync).',
|
||
'',
|
||
'**Test count: 87** (unchanged — metadata pass-through; no new branches to test).',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:18 → v0.2.0:19 is a drop-in. No schema, no SDK breaking change. Existing policies keep rendering bullets above (the default). The new JSON field appears in policies-list responses but is optional and back-compat: old SDKs ignore it.',
|
||
'',
|
||
'0.2.0:18 — **Discount Codes form polish: less typing, clearer intent.** Three small admin-UI changes that make the create + edit forms less footgun-prone.',
|
||
'',
|
||
'**Max-uses: checkbox + dependent number, not "0 = unlimited".** Previously the form had a single number input with a hint that read `"0 = unlimited"`. That meant the default value was `0`, which displayed as "no cap" but read like "0 uses allowed." Now it\'s a "Limit total uses" checkbox + a number input that only appears when the checkbox is checked (default 100). Unchecked = no cap is sent. Edit form matches.',
|
||
'',
|
||
'**Currency dropdown hides for percent + free_license codes.** A "50% off" code has no currency — neither does a free-license code. Previously the form still showed the SAT/USD selector for those kinds, which made buyers wonder what `50% off · SAT` meant. The kind-change listener now hides the currency field for `percent` and `free_license`, shows it for `fixed_amount`. Submit still defaults sensibly so existing forms keep working.',
|
||
'',
|
||
'**Featured: pill toggle, not buried checkbox.** The launch-special feature flag was the third checkbox on the form and getting missed. Replaced with a prominent gold-bordered pill toggle that flips to filled gold/navy when on. Click anywhere on the pill to toggle. Edit form matches; the toggle starts in the correct state for codes that were already featured.',
|
||
'',
|
||
'**Test count: 87** (unchanged — UI-only release).',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:17 → v0.2.0:18 is a drop-in. No schema, no SDK, no behavior change for buyers. Form fields persist the same way they always did.',
|
||
'',
|
||
'0.2.0:17 — **Discount Codes form usability.** Three concrete improvements to make the Discount Codes tab less type-heavy and more discoverable.',
|
||
'',
|
||
'**Product + policy scope as dropdowns, not free-text.** The create form previously had a "Restrict to product slug (optional)" text input that required operators to remember slugs exactly. Now it\'s a dropdown populated from the product list — pick "Any product" or any specific product. A second dependent dropdown appears for "Restrict to policy" once a product is chosen; it loads that product\'s policies on the fly. Both default to "Any" so the previous "no scope = global" behavior is preserved.',
|
||
'',
|
||
'**Datetime picker on expires_at.** The "Expires at (RFC3339)" text input is now a native `datetime-local` picker on both create and edit forms. Operators get a calendar + time spinner UI instead of having to hand-type `2026-12-31T23:59:59Z`. Submit converts back to RFC3339 (UTC) automatically. Empty stays empty (no expiry).',
|
||
'',
|
||
'**Edit form shows current scope read-only.** Previously the Edit modal didn\'t surface which product or policy a code was scoped to — operators reviewing a code couldn\'t tell at a glance. Now the top of the edit form shows "Applies to: [product] → [policy]" (or "Applies to: all products on this instance" for global codes) as a muted info block. Scope is still immutable (disable + create new to change), but at least it\'s visible.',
|
||
'',
|
||
'**Test count: 87** (unchanged — UI-only release).',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:16 → v0.2.0:17 is a drop-in. No schema, no SDK, no behavior change for buyers. The admin form fields persist the same way they always did (`product_slug`, `policy_slug` strings sent to the existing endpoints).',
|
||
'',
|
||
'0.2.0:16 — **Launch-special discount codes + marketing bullets + discount codes per-product UI.** Operators can now run public promotional discounts that auto-apply on the buy page, plus author marketing-copy bullets on tiers that don\'t map to real entitlements.',
|
||
'',
|
||
'**Launch-special (featured) discount codes (migration 0017).** Flag a discount code as `featured` and three things happen automatically: (1) the buy page renders a diagonal "LAUNCH SPECIAL" gold ribbon on every tier the code applies to; (2) the original price is struck through and replaced with the discounted price; (3) the purchase endpoint auto-applies the discount for buyers who don\'t type any code. Operator-typed codes still win — a buyer who pastes a different code in the form gets that code instead. When a featured code exhausts its `max_uses` cap (e.g. "first 100 buyers"), the ribbon disappears automatically and pricing reverts to standard. Expiry dates work the same way. New repo helper `find_applicable_featured_discount` picks the most specific match (policy > product > global) with operator priority by created_at.',
|
||
'',
|
||
'**Marketing bullets on policies.** Tier cards can now carry operator-controlled marketing copy in addition to the technical entitlements. Authored via a textarea on the policy create + edit forms (one bullet per line), stored as `metadata.marketing_bullets` on the policy, and rendered as ✓ checkmarks ABOVE the entitlement bullets on both the admin grid and the buy page. Use for things like "Up to 5 products" or "BTCPay integration" — features that are real but don\'t gate on a daemon-level entitlement. SDK consumers also receive these via `GET /v1/products/<slug>/policies` so dynamic pricing pages can render them too.',
|
||
'',
|
||
'**"Most popular" checkbox on draft tier cards.** Previously only the Edit modal had this toggle, which meant authoring a new tier required commit-then-edit to get the gold "Most Popular" pill. Now exposed on the draft create card too, alongside the new marketing-bullets textarea. Writes `metadata.highlight = true`.',
|
||
'',
|
||
'**Discount codes admin tab — per-product organization.** Replaces the flat-table view with per-product sections matching the Licenses + Subscriptions tab pattern. Each card shows a breakdown ("3 codes · 2 active · 1 featured"). Global codes (those without `applies_to_product_id`) get their own "All products (global)" section. Single-product instances continue to see a flat table. Each code row now carries a small gold "featured" badge when applicable.',
|
||
'',
|
||
'**Test count: 87** (unchanged — UI-heavy release; the existing CORS + entitlements-catalog regression tests cover the surface).',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:15 → v0.2.0:16 is a drop-in. Migration 0017 is additive (one nullable column on `discount_codes` + a partial index). All new behavior is opt-in — `featured` defaults to false on existing codes, `marketing_bullets` defaults to absent. No SDK changes; the new fields appear in JSON responses but old SDKs ignore unknown fields. Operators who don\'t use launch specials see no behavior change at all.',
|
||
'',
|
||
'0.2.0:15 — **Multi-draft tier authoring + custom durations on draft cards.** Small admin-UI release that fixes two papercuts from authoring fresh policies side-by-side.',
|
||
'',
|
||
'**Multi-draft survival.** Previously, committing one draft tier (clicking Create on a side-by-side draft card) reloaded the whole Policies tab — which wiped any other drafts the operator had open. Now the commit replaces ONLY that draft\'s grid slot with a finalized tier card; sibling drafts keep their in-progress input state untouched. Author Creator, Pro, Patron in parallel and click Create on each as it\'s ready, in any order.',
|
||
'',
|
||
'**Custom duration on draft cards.** The Duration dropdown on the draft tier card now has a "Custom (days)" option at the bottom. Selecting it reveals a number input; on submit, days × 86400 seconds is what gets sent. Matches the same pattern the Edit-policy modal has had (in raw seconds) — bringing it to the create flow so operators don\'t have to "create then immediately edit" for non-standard durations.',
|
||
'',
|
||
'**Test count: 87** (UI-only release).',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:14 → v0.2.0:15 is a drop-in. No schema, no SDK, no behavior change for buyers.',
|
||
'',
|
||
'0.2.0:14 — **Product entitlements catalog bug fix + drag-and-drop tier ordering.** One real bug fix that was silently breaking operator workflows, plus a UX rework of how tier ranks get set.',
|
||
'',
|
||
'**Bug fix: product entitlements catalog reads.** Every SELECT against the `products` table in repo.rs was missing the `entitlements_catalog_json` column. The PATCH handler wrote the catalog correctly, but every read returned it as null — so admin UI edits silently appeared to drop on the floor (operator adds entitlements, clicks Save, re-opens the editor, entitlements are gone). The data was always in the DB; only the API was blind to it. Now all four product SELECTs include the column. Net effect: catalog edits persist correctly; the bubble-picker on policy create / edit forms populates from the parent product\'s catalog.',
|
||
'',
|
||
'**Regression test.** New `product_entitlements_catalog_round_trips_through_list_endpoint` test creates a product, PATCHes a catalog, reads it back through `/v1/products`, and asserts the catalog is present. Would have caught this bug at PR time.',
|
||
'',
|
||
'**Drag-and-drop tier ordering.** The Policies tab\'s tier-card grid now supports drag-and-drop reordering. Operator drags any tier card to a new position; on drop, the daemon receives parallel PATCH /v1/admin/policies/<id> requests setting tier_rank 1..N based on the new visual order. The cursor flips to grab/grabbing on hover/drag, the dragging card visibly lifts + fades. Archived tiers aren\'t draggable (their position in the ladder is moot). The Edit-policy modal keeps a `tier_rank` number field for two edge cases drag-and-drop can\'t express: precise manual override, and blanking the field to remove a policy from the ladder entirely (so it\'s not offered as an upgrade target).',
|
||
'',
|
||
'**Two small copy fixes.** Section headers on the Policies tab now show just the product name (`Keysat`) instead of the redundant `Keysat — keysat`. The entitlements-catalog row editor\'s Description column placeholder shortened from "Description (shown on buy page tooltip)" to "Description (buyer tooltip)" so it fits in the column; full hover-explanation now lives in the input\'s title attribute.',
|
||
'',
|
||
'**Test count: 87** (was 85 — +1 entitlements catalog regression, +1 from CORS additions that were re-counted).',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:13 → v0.2.0:14 is a drop-in. No schema migrations, no SDK changes, no behavior change for buyers. Operators who had entitlements catalogs silently dropped on previous versions will see them populate correctly on next product PATCH. Operators who manually set tier_ranks via the number field will see those ranks reflected as the initial visual order in the policies grid.',
|
||
'',
|
||
'0.2.0:13 — **CORS on public endpoints.** Small, surgical release. Adds permissive cross-origin headers (`Access-Control-Allow-Origin: *`, all methods, all headers) to every public route so browsers can fetch from any origin. Unblocks a few things the static keysat.xyz / docs.keysat.xyz pages want to do directly without proxying:',
|
||
'',
|
||
'- The pricing page on docs.keysat.xyz fetches the live tier list from `licensing.keysat.xyz/v1/products/keysat/policies` so it always reflects what\'s actually configured on the master Keysat. No more out-of-sync static copies.',
|
||
'- The agent-friendly pitch on keysat.xyz can now link to `licensing.keysat.xyz/v1/openapi.json` and have agents fetch it in-browser without setup.',
|
||
'- Third-party tooling (SDK demos, integration sandboxes) can call `/v1/validate` from a browser without a server-side proxy.',
|
||
'',
|
||
'**Security posture is unchanged.** `Access-Control-Allow-Credentials` is deliberately OFF. That combination — `ACAO: *` plus no-credentials — means a cross-origin page can read public responses but cannot ride a logged-in admin session cookie to hit `/v1/admin/*`. Admin endpoints still require an explicit Bearer token; that token isn\'t auto-attached by browsers; nothing changes for operators.',
|
||
'',
|
||
'**Test count: 85** (was 83 — +2 new CORS regression tests covering both the ACAO header on public endpoints and OPTIONS preflight handling).',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:12 → v0.2.0:13 is a drop-in. No schema migrations, no SDK changes, no admin-side behavior change. The only operator-visible difference is that browser-side scripts fetching public endpoints from a different origin start working.',
|
||
'',
|
||
'0.2.0:12 — **Settings tab + agent-friendly operator API.** Major release that consolidates operator configuration into the admin UI and ships first-class agent / AI-automation support: OpenAPI 3.1 spec, scoped API keys, agent integration guide. Plus a slate of UX cleanups carried over from operator testing.',
|
||
'',
|
||
'**New Settings tab in the admin UI.** Three subsections in one place:',
|
||
'',
|
||
'- **Operator name** — the display name shown on /buy/<slug> + thank-you pages. Was in StartOS Actions; now lives where the rest of operator configuration lives.',
|
||
'- **Payment providers** — BTCPay + Zaprite connect / disconnect / activate, with provider status and active-provider toggle in one view. Replaces 5 separate StartOS Actions. Zaprite is grayed out + tagged "locked" for operators on Creator tier; clicking through opens the Pro upgrade page (gated on the `zaprite_payments` entitlement).',
|
||
'- **API keys** — generate / list / revoke scoped Bearer tokens for agents and automation.',
|
||
'',
|
||
'**Scoped API keys (migration 0016).** Master `KEYSAT_ADMIN_API_KEY` is full-access and reserved for the operator. Scoped keys are additional credentials with bounded permissions. Pick a role at generate time:',
|
||
'',
|
||
'- `read-only` — list everything, mutate nothing',
|
||
'- `license-issuer` — reads + issue / revoke / suspend / change-tier on licenses; cannot touch products, policies, or codes',
|
||
'- `support` — license-issuer + cancel subscriptions + deactivate machines',
|
||
'- `full-admin` — every scope except operator-only settings (payment providers, operator name, generating other API keys)',
|
||
'',
|
||
'Tokens look like `ks_<43 chars>`, are returned ONCE on create (never again — only sha256 stored), and can be revoked instantly from the same UI. Endpoints that have been wired through `require_scope` accept either the master key or a scoped key with the appropriate role. Endpoints not yet wired stay master-only — secure by default.',
|
||
'',
|
||
'**OpenAPI 3.1 spec.** `GET /v1/openapi.json` returns a curated, stable spec covering the agent-relevant subset of endpoints. No auth required. Drop the URL into a Custom GPT, OpenAI Assistant, LangChain, Claude Code, or any agent framework with OpenAPI support and the agent discovers Keysat\'s API automatically.',
|
||
'',
|
||
'**Agent integration guide.** New `KEYSAT_AGENT_GUIDE.md` doc covering scoped key generation, role-to-scope mapping, error envelope conventions (every error response returns `{ok: false, error: <stable_code>, message: ...}` with stable codes like `tier_cap`, `not_found`, `license_revoked`), webhook event types, and worked recipes for common automation patterns.',
|
||
'',
|
||
'**Landing page — agent-friendly section.** New section at keysat.xyz/#agents calling out OpenAPI discovery, scoped API keys, and HMAC-signed webhooks, with a link to the integration guide. Top-nav gains "Agents" anchor.',
|
||
'',
|
||
'**Enforce mode killed.** The compile-time `KEYSAT_LICENSE_ENFORCE=1` flag is gone. Every Keysat binary now boots permissively — a missing or invalid self-license falls back to the Creator (free) tier instead of refusing to start. Simpler mental model: every Keysat install is Creator-tier out of the box, paid tiers add entitlements. The "Unlicensed" label is gone from the admin UI; the free state surfaces as "Creator" everywhere.',
|
||
'',
|
||
'**Zaprite gated to Pro (`zaprite_payments` entitlement).** Both Connect Zaprite and Activate Zaprite now check the daemon\'s self-tier and return 402 with an upgrade URL when the entitlement is missing. BTCPay (Bitcoin / Lightning) remains free on every tier. Renamed the entitlement from `card_payments` to `zaprite_payments` to reflect that the Zaprite gateway covers cards plus Apple Pay, bank transfers, and more — not just credit cards.',
|
||
'',
|
||
'**StartOS Actions reduced to 4 essentials.** Pruned 8 actions that duplicated admin-UI functionality. What remains: `Set web UI password`, `Activate Keysat license`, `Show license status`, `Show credentials`. Everything else (operator name, payment providers, products, policies, licenses, codes, machines, webhooks, audit log, scoped API keys) lives in the admin UI under Settings or the workspace sidebar.',
|
||
'',
|
||
'**Creator code cap bumped 5 → 10.** Promo campaigns multiply faster than products do; doubling the active-codes cap removes a friction point that wasn\'t actually driving upgrades. Existing operators see the new cap immediately.',
|
||
'',
|
||
'**Machines tab — global default view + drill-down.** The old "paste a license ID to see anything" form is gone. Default view now lists every machine across every license, grouped by product, with the same product / status filter pills used by Licenses and Subscriptions. Quick-stats row shows total machines + active count + top platform. Each row shows buyer email, license id (with status pill), hostname, platform, last IP, last heartbeat as a relative date. The Licenses tab now has a "Machines" button on every license row that drills directly into that license\'s machines without copy-pasting UUIDs. New repo helper `list_machines_admin` joins machines × licenses × products server-side so there\'s no N+1 fetch. `GET /v1/admin/machines` is backwards-compatible — the old `?license_id=X` form still works (it\'s how the drill-down passes through).',
|
||
'',
|
||
'**Buyer-facing copy aligned with the new positioning.** Thank-you page + buy page footers, registry tagline, OpenAPI spec description, every SDK README, and the integration guide all now say "Bitcoin-native self-hosted software licensing." The older "Bitcoin-paid" phrasing is fully retired from production-facing surfaces.',
|
||
'',
|
||
'**UX polish.** Welcome card removed from Overview (was redundant). Analytics opt-in card aligned with other Overview cards and copy tightened. support.html stripped down to Patron / Lightning / OpenSats (the speculative "what funds go toward" section is gone). Thank-you page status-detail copy honest about Lightning vs on-chain settle timing. Reason-modal label has proper vertical spacing. Stray "legacy" references in user-facing copy swept.',
|
||
'',
|
||
'**Cross-product safety doc.** New §9a in KEYSAT_INTEGRATION.md explaining the same-keypair-multiple-products situation: licenses for any product on an operator\'s instance signature-verify against the same key, so applications must assert `payload.product_slug` after offline verify OR pass `product_slug` to online validate. Daemon already enforces it when product_slug is passed; the SDK doesn\'t auto-assert offline. New bullet in §15 Common mistakes points at §9a.',
|
||
'',
|
||
'**Test count: 78 + new tests landed in this release** (full suite passes across the 8 categories: unit / api / migrations / btcpay / crosscheck / etc.). The api.rs test for `payment_provider_preference_round_trip` was updated to pin the test daemon to a licensed Pro tier so Zaprite activation still passes through the new gate.',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:11 → v0.2.0:12 is a drop-in. Migration 0016 is additive (one new table). No SDK changes. **One operator-facing behavior change**: operators currently on a Pro/Patron self-license should rename `card_payments` → `zaprite_payments` in their `keysat` product\'s entitlements catalog + on Pro/Patron policy rows. Until they do, Connect/Activate Zaprite will 402 even though they bought the right tier. (Master operator: run this rename on your master instance, then re-mint and re-issue customer self-licenses.) Pre-launch this affects very few people.',
|
||
'',
|
||
'0.2.0:11 — **Policy archive + saner delete semantics + brand-consistent confirm modals.** Three closely-related cleanups driven by hands-on testing: policies can now be archived (soft-hide) instead of forced through hard-delete, the safe-delete check stopped treating revoked-license tombstones as blockers, and every confirm dialog in the admin UI now uses the in-app overlay card instead of the browser-native one.',
|
||
'',
|
||
'**Archive a policy.** New Archive button on every tier card. Archived policies are hidden from the admin grid (toggle "Show archived" at the top of the Policies tab to reveal them), hidden from `/buy/<slug>`, and the renewal worker refuses to renew subscriptions tied to an archived policy (dispatches a `subscription.renewal_skipped` webhook with `reason: policy_archived`). Existing licenses keep validating because entitlements are signed into the LIC1 payload — the policy row is not consulted at validate time. Reversible: Unarchive button on archived cards puts everything back. Migration 0015 adds `policies.archived_at TEXT NULL`; existing rows default to NULL (live).',
|
||
'',
|
||
'**Safe-delete ignores tombstones.** Previously, any reference at all — including revoked-license rows kept for audit — blocked the safe-delete path and pushed the operator to force-delete. Now the safe-delete check counts only **live licenses** (status != revoked), **settled invoices**, and **active subscriptions** (status in active/past_due). Revoke an outstanding license and the policy is immediately safe-deletable, with the cascade sweeping up the revoked tombstone + any dead invoices. Force-delete still works the same way (wipes everything regardless), and the cascade now also handles `tier_changes` rows + nulls out `discount_codes.applies_to_policy_id` so it doesn\'t fall over on FK violations.',
|
||
'',
|
||
'**Branded confirm dialogs.** Every `window.confirm()` call in the admin UI replaced by a new `confirmModal()` overlay-card helper styled the same as the existing `reasonModal()`. No more "immense-voyage.local:62488" host string at the top of irreversible-action confirmations. Sites swept: policy delete, product delete, analytics-ID wipe, admin tier downgrade, discount-code delete, machine force-deactivate, webhook subscription delete. Modal copy adapts to context — e.g. deleting a policy with zero licenses now just asks "Sure?" without spelling out the cascade implications.',
|
||
'',
|
||
'**Test count: 78** (no test changes; framework PR coming separately).',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:10 → v0.2.0:11 is a drop-in. Migration 0015 is additive (one nullable column + an index). No SDK changes. No behavior change for operators who don\'t use the new Archive button — existing policies stay live until someone explicitly archives them.',
|
||
'',
|
||
'0.2.0:10 — **Licenses + Subscriptions tabs reorganized to match Products + Policies.** Both tabs now group by product (matching the per-product card sections used elsewhere in the admin UI), with product-filter pills + per-product license counts at the top. Single-product instances continue to see a flat table; multi-product instances see one section per product with a status breakdown subtitle ("3 active · 1 revoked · 2 expired"). Search results bypass grouping (search is global across all products).',
|
||
'',
|
||
'**Licenses tab gains a quick-stats row** matching the Overview dashboard: Licenses, Active, Revoked, Expiring within 30 days. Scope follows the active product filter — pick a product, the stats reflect just that product. Hover the "?" icons next to each stat label for definitions.',
|
||
'',
|
||
'**Subscriptions tab gains a Product column + status filter pill counts.** "Active (3) · Past due (0) · Cancelled (1) · Lapsed (0)" so operators see the breakdown at a glance. Status badges hover-explain what each state means ("past_due → renewal invoice exists, license still valid through grace window," etc.).',
|
||
'',
|
||
'**Inline reason modals replace browser prompt() dialogs.** Cancelling a subscription or revoking / suspending a license used to fire a jarring native prompt() box and a separate confirm(); both flows are now the same overlay-card UX as Change Tier — title, contextual message, optional warning banner for irreversible operations, audit-reason textarea, Cancel / Confirm buttons. Operators get clearer copy + a less-noisy interaction.',
|
||
'',
|
||
'**Click-to-copy IDs.** License IDs and subscription license_ids in both tabs render as clickable codes — click to copy the full UUID to clipboard with a brief "✓ copied" indicator. Replaces the older hover-to-see-full-id pattern; one fewer step to grab an id for SDK debugging or audit-log spelunking.',
|
||
'',
|
||
'**Relative dates with absolute hover.** `5/12/2026, 2:31:00 PM` becomes `in 3 days` / `12 hours ago` / `2 months ago` with the absolute timestamp in the hover tooltip. Applied to license issued/expires + subscription next_renewal. Operators care about "is this happening soon?" more than the wall-clock value; full timestamp still one hover away.',
|
||
'',
|
||
'**Manual-issue form on Licenses tab uses help icons.** Verbose hint blocks under each input replaced with `?` hover tooltips — same compact-form treatment as the Products + Policies tabs got in :8 / :9.',
|
||
'',
|
||
'**Test count: 78** (UI-only release, unchanged from :9).',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:9 → v0.2.0:10 is a drop-in. No schema, SDK, or behavior change. Pure admin UI.',
|
||
'',
|
||
'0.2.0:9 — **Side-by-side tier-card policy authoring + form polish.** The Policies tab\'s table view is gone — replaced with a card grid where each existing policy renders as a buy-page-style tier card sitting alongside a dashed "+ Add tier" placeholder. Click the placeholder and it morphs into an editable draft card with form fields inline; submit "Create" on the card and it flips back to a read-only tier preview. **Multiple drafts can coexist** in the same product\'s grid, so operators can author Core / Pro / Patron in parallel and visually compare what each will look like to a buyer before committing any of them. Same visual language as the buy page, so what you see while authoring is what buyers see.',
|
||
'',
|
||
'**Form polish.** New `helpIcon()` helper renders a small "?" hover-tooltip next to field labels — replaces the verbose hint text under inputs that was making forms feel cluttered. Applied first to the product create form (Display name → Slug → Description → Price all use help icons now); spread to other forms incrementally over follow-up releases.',
|
||
'',
|
||
'**Auto-slug from display name.** Type "Bitcoin Ticker Pro" into the new product form\'s Display name field and the Slug field auto-fills with `bitcoin-ticker-pro` as you type. Operators can still override; the auto-fill stops mirroring once they edit the slug manually. Cuts a step out of the most common product-creation path.',
|
||
'',
|
||
'**Legacy create-policy disclosure removed from the UI.** The "Create a new policy" form that used to sit at the top of the Policies tab is gone — the card grid below replaces it for all common authoring. Advanced fields (custom grace period, tip recipient, tier rank) still live on the existing Edit modal of any committed tier card; create-the-basics-then-edit-for-advanced is the new flow.',
|
||
'',
|
||
'**No code surface change for SDKs or buy page.** This release is admin-side UX only. The catalog work shipped in v0.2.0:8 still applies (closed-list bubble pickers, display-name rendering); the new draft cards just package those into a more usable authoring flow.',
|
||
'',
|
||
'**Test count: 78** (unchanged from :8 — UI-only release).',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:8 → v0.2.0:9 is a drop-in. No schema changes, no SDK changes. Operators see the new card-grid layout the next time they open the Policies tab.',
|
||
'',
|
||
'0.2.0:8 — **Entitlements catalog on products.** Operators define each product\'s entitlements once with display names + descriptions; policies pick from that closed list with a click-to-toggle bubble picker; the buy page renders human-readable names ("AI summaries") with descriptions as tooltips, never the raw slug ("ai_summaries"). Existing products are auto-backfilled from the union of their policies\' current entitlements (with name = slug-with-underscores-stripped) — operator can edit afterward to add proper descriptions.',
|
||
'',
|
||
'**Admin UI changes.** Product create + edit forms gain an "Entitlements catalog" editor: repeating rows for slug + display name + description, with an "+ Add entitlement" button. Policy create + edit forms swap the free-text entitlements textarea for a row of clickable pill chips populated from the parent product\'s catalog — click each chip to toggle that entitlement on or off for the policy. Policies list table renders entitlement display names (resolved via catalog) instead of slugs.',
|
||
'',
|
||
'**Buy page rendering.** Tier cards now show display names with the description as a hover tooltip on each entitlement bullet. Falls back to raw-slug rendering for legacy entries that predate the catalog (no buy-page-side breakage on upgrade).',
|
||
'',
|
||
'**Closed-list enforcement.** Once a product has a non-empty catalog, policy create + update endpoints reject any entitlement slug that\'s not in the catalog with a clear error pointing at the right path ("add it to the product\'s entitlements catalog first"). Products without a catalog stay in legacy "free-text" mode where any string is accepted — back-compat preserved.',
|
||
'',
|
||
'**SDK support.** All four SDKs (`@keysat/licensing-client`, `keysat-licensing-client` Rust crate, `keysat-licensing-client` Python package, `keysat-client-go`) bumped to 0.3.0. `Client.listPublicPolicies()` response now includes `product.entitlementsCatalog` (camelCase TS / snake_case Rust + Python + Go) — an array of `{slug, name, description}` so SDK consumers\' in-app tier pickers can render the same human-readable names + tooltips the buy page does. Empty array on legacy products without a catalog.',
|
||
'',
|
||
'**Schema (migration 0014).** Adds `products.entitlements_catalog_json` (nullable). Auto-backfill per product: collect the distinct union of all entitlement slugs across the product\'s policies, build a catalog with `name = slug.replace("_", " ")` and empty description, write it. Products with no policy entitlements anywhere stay NULL (legacy mode preserved).',
|
||
'',
|
||
'**Documentation.** KEYSAT_INTEGRATION.md section 8 ("Picking entitlement names") gets a new subsection explaining the catalog: how the bubble picker works, how the buy page renders, how SDKs surface it, and the catalog-stability rule (renaming a slug breaks existing licenses).',
|
||
'',
|
||
'**Test count: 78** (was 77; +1 for `migration_0014_backfills_entitlements_catalog_from_policies`).',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:7 → v0.2.0:8 is a drop-in. Migration 0014 is additive; auto-backfill runs at boot. No behavior change for operators who don\'t open the product editor — old free-text policy entitlements continue to work. Operators who open the editor see the catalog already populated from their policies\' existing entitlements; they can refine display names + add descriptions.',
|
||
'',
|
||
'**What\'s next (v0.2.0:9):** side-by-side card-grid policy authoring UI. The current "open a disclosure, fill a form, click Create, repeat" flow gets replaced by a tier-card grid where operators can see existing policies as buy-page-style cards alongside editable draft cards, with multiple drafts allowed simultaneously. Lands as its own focused release on top of this.',
|
||
'',
|
||
'0.2.0:7 — Marketing-copy alignment. Package short and long descriptions now read "Bitcoin-native self-hosted licensing service for software creators" — matches keysat.xyz and the new positioning. Long description also calls out Zaprite (Bitcoin + cards), recurring subscriptions, and tier upgrades, all of which shipped in earlier :N revisions but weren\'t reflected in the registry listing. Same change applied to the daemon Cargo.toml description, repo READMEs, and the in-StartOS About panel for consistency. No code changes; pure copy.',
|
||
'',
|
||
'0.2.0:6 — **Recurring subs + trials + self-tier live refresh actually work now.** Major bug-and-UX-fix release driven by hands-on testing of v0.2.0:5. The recurring-sub feature shipped in :4 had a critical gap: buying a recurring policy issued a license but never created the corresponding subscription row, so the renewal worker never picked it up — purchases silently behaved like one-shots. The trial flow shipped with `trial_days` configurable in admin but the field had zero effect on the purchase path. And admin tier changes on the daemon\'s own license never propagated to the running daemon, making it impossible to test Creator-tier gates on the master Keysat. This release fixes all three plus a slate of UX papercuts found during testing.',
|
||
'',
|
||
'**Recurring purchases now create subscriptions.** `issue_license_for_invoice` calls `subscriptions::create_subscription` whenever the resolved policy has `is_recurring=1`. The Subscriptions tab populates correctly; the renewal worker sees the row; cancellation works. Idempotent against webhook re-delivery.',
|
||
'',
|
||
'**Free trials actually work.** When a buyer hits "Pay with Bitcoin" on a recurring policy with `trial_days > 0`, the daemon now: (a) synthesizes a free invoice via the same shortcut used for free-license-code redemptions, (b) issues a license inline with `expires_at = now + trial_days`, (c) creates the subscription with `next_renewal_at = trial_end` so the renewal worker fires the FIRST paid invoice when the trial ends, (d) returns the license key directly with no checkout step. The buy page CTA flips to "Start N-day free trial" so the buyer knows they\'re not being charged today. Discount codes are intentionally ignored on trial purchases (trial = free; layering a discount is a no-op). Trial license carries the TRIAL flag on the signed payload.',
|
||
'',
|
||
'**Self-tier live refresh.** The daemon\'s own tier (`state.self_tier`) was previously loaded from the on-disk LIC1 key at boot and never refreshed — entitlements baked into the signed payload at signing time were the daemon\'s permanent reality. Now there\'s a `license_self::refresh_self_tier_from_db` helper that re-reads the local `licenses` row and rebuilds `state.self_tier` from LIVE entitlements. Wired to fire (a) once at boot right after `check_at_boot`, (b) every hour as a background task, (c) on demand via `POST /v1/admin/self-license/refresh`. Admin tier changes now propagate. This is the same online-entitlement-refresh pattern any operator should implement in their own app — Keysat dogfoods it for itself.',
|
||
'',
|
||
'**Renewal-pending webhook payload enriched.** `subscription.renewal_pending` now includes `buyer_email`, `product_id`, `policy_id`, `cycle_start_at`, `cycle_end_at`, `due_at`, and `is_first_paid_cycle` so operators\' webhook receivers have everything they need to render and send "your free trial is ending" / "your monthly renewal is due" emails to the buyer with the checkout URL. (Without this, renewal invoices were created server-side but no one knew about them — the buyer had no way to learn they needed to pay.)',
|
||
'',
|
||
'**Admin Change Tier modal redesigned.** The "skip_payment" toggle is gone — admin tier changes always apply as comp from the UI now. Paid tier changes are buyer-initiated via the SDK\'s in-app upgrade flow; admin path is for operators who want to give someone a free upgrade or fix a screwup. Reduces the attack surface of "operator generates invoice, dismisses modal, orphan invoice lives on the provider." The modal also now detects downgrades (target rank or price < current), shows a yellow warning banner listing the entitlements the buyer will lose, and confirms via dialog. The dropdown shows the current tier in disabled state with "(current)" suffix — operators see what they\'re starting from but can\'t pick a no-op.',
|
||
'',
|
||
'**Self-tier guard.** `POST /v1/admin/licenses/<id>/change-tier` now refuses when `<id>` is the daemon\'s own self-license, with a clear error pointing at either the master Keysat\'s re-mint flow or the file-rename trick (`mv /data/keysat-license.txt /data/keysat-license.txt.bak; restart`) for testing Creator-tier gates.',
|
||
'',
|
||
'**Zaprite webhook flow improved.** Connect Zaprite now shows the EXACT `https://your-keysat-url/v1/zaprite/webhook` URL to paste (was a placeholder before, which Zaprite\'s form rejected). New "Show Zaprite webhook setup" StartOS Action surfaces the URL persistently for operators who skipped the step on first connect. Connect-while-already-connected returns 409 Conflict with a clear message instead of overwriting silently (BTCPay already had this guard).',
|
||
'',
|
||
'**Single "Switch active payment provider" StartOS action** replaces the two confusing "Activate BTCPay" / "Activate Zaprite" actions. Dropdown-driven, pre-fills with currently-active provider so opening it is informative.',
|
||
'',
|
||
'**UX polish on the admin dashboard:**',
|
||
'- Policy list duration column is human-readable (`1 year` / `1 week` / `perpetual`) instead of raw seconds (`31536000s`).',
|
||
'- "Preview buy page" button on each product\'s policies card opens `/buy/<slug>` in a new tab.',
|
||
'- Buy page tier cards: clicked button reads "Selected" while others stay "Select" — clearer "this is the active choice" cue.',
|
||
'- Licenses tab POLICY column shows display name primary with slug secondary (was slug-only).',
|
||
'- Thank-you page copy: "Lightning settles in seconds; on-chain typically 10–20 minutes" instead of misleading "next block confirms" for Lightning payments.',
|
||
'',
|
||
'**KEYSAT_INTEGRATION.md adds section 0a "How enforcement actually works"** — the offline-vs-online framing every operator hits when they realize they want to revoke / downgrade / lapse a license. Walks through the two patterns (A: true perpetual, offline-only; B: perpetual price, online-enforced) with TS code samples and the design dials operators pick.',
|
||
'',
|
||
'**Test count: 77** (unchanged). The bug fixes are above the renewal-worker tests\' scope (those tests construct subscriptions explicitly via `create_subscription`, bypassing the broken purchase path); test additions deferred to the v0.3 work that\'ll cover the integration paths properly.',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:5 → v0.2.0:6 is a drop-in. No new schema migrations. No behavior change unless you actively use recurring policies, trials, or admin tier changes — all of which were broken before and now work.',
|
||
'',
|
||
'0.2.0:5 — **In-place tier upgrades are functional end-to-end.** Buyers can self-serve "upgrade to Pro" inside the operator\'s app — they pay only the prorated difference for the time remaining in their current cycle, the existing license keeps its key, and the daemon flips entitlements on next online validation. Operators can force-change any license to any policy from the admin UI, with optional comp-mode (skip the invoice).',
|
||
'',
|
||
'**Buyer flow.** New `POST /v1/upgrade-quote` returns the prorated charge in the listed currency: "Standard $25/mo → Pro $75/mo with 15 days remaining = $25.00 today, $75.00 next cycle." `POST /v1/upgrade` creates a payment provider invoice for the prorated charge and returns a checkout URL. When the invoice settles, the webhook handler flips the license\'s policy_id + entitlements + max_machines + expires_at and any tied subscription\'s policy_id + listed_value + period_days. The signed license key stays the same — the buyer\'s app just sees the new entitlements on its next call to `/v1/validate`.',
|
||
'',
|
||
'**Admin flow.** New `POST /v1/admin/licenses/:id/change-tier` for force-changes. Two modes: `skip_payment: true` applies on the spot for comp upgrades / support fix-ups (no invoice, audit-logged); `skip_payment: false` creates an invoice and returns the checkout URL the operator forwards to the buyer through whatever channel (email, chat, etc.). Bypasses ladder rules — admin can move sideways, downgrade perpetuals, or change to/from policies that aren\'t in any ladder.',
|
||
'',
|
||
'**Tier ladder.** Policies gain a `tier_rank` integer column (NULL = excluded from buyer-facing upgrade flows). Operators set this in the policy editor: free=0, standard=1, pro=2, etc. The buyer endpoint enforces that target.tier_rank > current.tier_rank for upgrades; sideways and reverse moves return 400 "admin-only".',
|
||
'',
|
||
'**Recurring downgrades, scheduled at cycle boundary.** When the admin records a downgrade tier_change with `effective_at = next_renewal_at`, the renewal worker checks for pending changes before pricing the next cycle and applies them in place. This means "downgrade me at end of cycle" actually fires correctly — the next invoice bills at the new (lower) tier, not the old one. Audit-logged with `actor=system`, `applied_via=renewal_worker`.',
|
||
'',
|
||
'**New tables + columns.** Migration 0013 adds `policies.tier_rank` and a new `tier_changes` audit table (one row per upgrade or downgrade ever applied; FK\'d to license + invoice + both policies). Schema is purely additive — existing licenses and policies are untouched and inherit `tier_rank = NULL` (not in any ladder).',
|
||
'',
|
||
'**Webhook event.** `license.tier_changed` fires whenever a license\'s policy changes, with `actor=buyer|admin|system` so downstream tooling can distinguish self-service vs operator vs scheduled changes.',
|
||
'',
|
||
'**Test count: 77** (was 57 at v0.2.0:4). +5 covering renewal-worker pending-tier-change hook + admin endpoint variants; +6 buyer-endpoint variants + webhook tier-change branch; +8 unit tests for the quote/apply math; +1 migration regression test for the 0013 schema.',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:4 → v0.2.0:5 is a drop-in. Migration 0013 is additive only. No behavior change for existing operators unless they explicitly set tier_rank on their policies and start using the new endpoints.',
|
||
'',
|
||
'0.2.0:4 — **Recurring subscriptions are functional end-to-end.** Migration 0011 stopped being dormant: operators on Pro/Patron tier can now mark a policy as recurring, the renewal worker creates fresh invoices on cadence, the buy page renders subscription pricing, and both operator and buyer can cancel cleanly.',
|
||
'',
|
||
'**Admin UI.** Policy editor (create + edit) gains a "Recurring subscription (Pro)" section: tick the box, pick a cadence (Monthly / Quarterly / Semi-annual / Annual / Custom days), set grace-period days (default 7) and optional free-trial days. The Policies list table shows a gold "every Nd" badge alongside the existing trial badge so recurring tiers are recognisable at a glance. Free / Creator-tier operators see a 402 with an upgrade link if they try to flip a policy to recurring — same gating pattern as the existing product/policy/code caps.',
|
||
'',
|
||
'**Buy page.** Recurring tier cards render a "Renews monthly / annually / every N days" line plus a "/mo" / "/yr" / "/Nd" suffix on the headline price ("$25 / mo" not just "$25"). First-cycle trial banner shows when trial_days > 0 ("14 day free trial"). Tier-switching JS keeps the cadence suffix in sync as the buyer clicks between tiers.',
|
||
'',
|
||
'**Renewal worker.** Background worker sweeps every 60 seconds for subs whose `next_renewal_at` has passed. SAT-priced subs use identity conversion (no rate fetcher); fiat-priced subs re-quote each cycle so a billing cycle always reflects the BTC/USD rate at the moment of renewal (per MULTI_CURRENCY_DESIGN). Failed renewals back off on a 5min → 30min → 2h → 6h → 12h schedule, capped at 5 consecutive failures before the worker stops touching the row. Past-due subs whose grace window has elapsed transition to `lapsed` automatically.',
|
||
'',
|
||
'**New Subscriptions tab in the admin UI.** Lists all subs with status filter pills (All / Active / Past due / Cancelled / Lapsed). Each row shows the license, cadence, listed price (in original currency), status, next renewal, consecutive failures, and a one-click Cancel button (confirms with an optional reason captured to the audit log). Cancellation is non-destructive — the license stays valid through the end of the current billing cycle, the renewal worker just stops creating new invoices.',
|
||
'',
|
||
'**Buyer self-service cancel.** New `POST /v1/subscriptions/cancel` endpoint takes the buyer\'s signed license key as auth (no admin token, no cookie) and cancels the tied subscription. SDKs can wire a "Cancel subscription" button in the operator\'s app without involving the operator\'s support workflow. Bad/wrong/revoked keys all return 401 (not 404) so a probe can\'t enumerate which licenses have active subs.',
|
||
'',
|
||
'**Webhooks.** New `subscription.cancelled` event fires with `actor=admin|buyer` so operators can distinguish self-service cancels in their downstream tooling. The existing `subscription.lapsed` event fires when the worker transitions a past-due sub past its grace window.',
|
||
'',
|
||
'**Auto-charge via saved payment profiles is NOT in this release.** The renewal worker creates fresh invoices that the buyer must pay manually. v0.2.0:5+ adds the auto-charge path (Zaprite\'s `paymentProfileId` flow). Until then, subscriptions are "we send you a fresh invoice link every month" — closer to GitHub Sponsors than Stripe.',
|
||
'',
|
||
'**Test count: 57** (was 42 at v0.2.0:3). +7 renewal-worker integration tests, +4 admin policy tests covering recurring fields and the Pro-tier gate, +4 cancellation tests covering both admin and buyer paths.',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:3 → v0.2.0:4 is a drop-in. The schema columns for recurring policies were already added in v0.2.0:2 (migration 0011); existing policies have `is_recurring=0` so the renewal worker has nothing to do. No behavior change unless an operator explicitly creates a recurring policy.',
|
||
'',
|
||
'0.2.0:3 — **Durable payment-provider switching.** Fixes a gap from v0.2.0:2 where Connect Zaprite swapped the in-memory provider but BTCPay silently re-took active on the next daemon restart. Both providers\' configurations can now coexist, with a persisted preference flag determining which one is active. New "Activate BTCPay" / "Activate Zaprite" StartOS Actions let operators flip between configured providers in one click without re-running Connect. Disconnect on either provider clears the preference only if it pointed at the disconnected one — symmetric handling preserves operator intent.',
|
||
'',
|
||
'New endpoints: `GET /v1/admin/payment-provider/status` (both configs\' state + active preference in one call), `POST /v1/admin/payment-provider/activate` (flip active without re-authorizing). The boot-time loader now reads the persisted preference, so what an operator activates today is what loads tomorrow regardless of which config rows happen to be in the DB.',
|
||
'',
|
||
'Test count: 42 (added `payment_provider_preference_round_trip` covering the full lifecycle).',
|
||
'',
|
||
'0.2.0:2 — **Zaprite payment provider lands.** Operators can now choose between BTCPay (Bitcoin-only, you run the BTCPay Server yourself) and Zaprite (Bitcoin + fiat cards via Stripe/Square, brokered by Zaprite, settles to your connected wallets). Switching is Disconnect → Connect via new StartOS Actions ("Connect Zaprite" / "Disconnect Zaprite" / "Check Zaprite connection"). Existing BTCPay-connected operators see zero change unless they explicitly switch.',
|
||
'',
|
||
'How it works: paste your Zaprite API key (created at app.zaprite.com → Settings → API) into the Connect Zaprite action. Daemon validates the key, swaps the active provider atomically. Then add a webhook in your Zaprite dashboard pointing at `<your-keysat-url>/v1/zaprite/webhook`.',
|
||
'',
|
||
'**Webhook security.** Zaprite does NOT sign webhook deliveries (verified May 2026 against their public OpenAPI + dashboard). Keysat\'s defense is the externalUniqId round-trip: we attach our local invoice UUID at order creation, and the webhook handler trusts the body only insofar as the order id resolves to a local invoice in an expected state. An attacker spoofing a webhook would need to know a UUID we never put on the wire to reach a real local invoice.',
|
||
'',
|
||
'**Migration 0011 (dormant) lands the recurring-subscriptions schema** — `subscriptions` + `subscription_invoices` tables, plus `is_recurring`/`renewal_period_days`/`grace_period_days` (default 7)/`trial_days` (default 0) columns on policies. No daemon code uses these yet; phases 2-6 of `RECURRING_SUBSCRIPTIONS_DESIGN.md` ship in follow-up releases. The schema is purely additive and existing policies inherit the safe defaults.',
|
||
'',
|
||
'**Migration 0012** adds the `zaprite_config` table (singleton row mirroring `btcpay_config` from migration 0002).',
|
||
'',
|
||
'**Limitation called out cleanly:** Zaprite\'s API has no native subscription endpoints — Keysat\'s renewal worker (when it ships) drives the cycle on our side and uses Zaprite\'s `paymentProfileId` + `POST /v1/orders/charge` to charge saved cards each cycle. This is actually a cleaner model than provider-managed subscriptions because Keysat keeps the source of truth on when to bill.',
|
||
'',
|
||
'**Test count: 41** (was 39; +2 covering the Zaprite webhook event-parsing contract and the provider kind self-identification, +1 covering migration 0011\'s populated-data backfill contract).',
|
||
'',
|
||
'**Upgrade path.** v0.2.0:1 → v0.2.0:2 is a straight drop-in. Two new SQLite migrations (0011, 0012); both are additive only. No behavior change for current operators unless they explicitly run Connect Zaprite.',
|
||
'',
|
||
'0.2.0:1 — Buy-page discount-code box no longer shows a "FOUNDERS50" placeholder. Empty placeholder now; buyers paste their actual code without a misleading hint.',
|
||
'',
|
||
RELEASE_NOTES,
|
||
].join('\n\n')
|
||
|
||
export const v0_2_0 = VersionInfo.of({
|
||
version: '0.2.0:22',
|
||
releaseNotes: { en_US: ROUTINE_NOTES },
|
||
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
||
// SQLite-level migrations live separately under
|
||
// licensing-service/migrations/ and run at daemon boot regardless
|
||
// of the ExVer-level version graph.
|
||
migrations: {},
|
||
})
|