// 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 `:`. // The `` 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:32 — **Per-product policy cap also pre-checked + grandfathered.** Extends the v0.2.0:31 cap-handling pattern to the third tier-enforced surface (Creator caps each product at 5 policies). Same shape, just scoped to a single product instead of the whole instance.', '', '**Pre-check.** When the operator clicks "+ Add tier" on a product that already has 4 of 5 policies, the draft tier card opens with a gold-bordered "Approaching cap" warning at the top: "You\'re at 4/5 policies on this product. Creating one more will hit your Creator tier cap." Includes a direct upgrade link. The existing 402 → upgrade modal still fires if the operator pushes through.', '', '**Grandfather.** When a single product carries more policies than the current tier\'s per-product cap (e.g. operator on Pro authored 7 policies, then downgraded to Creator), that product\'s card now renders a persistent grandfather banner above its tier grid: "Grandfathered: 7 policies on this product active vs Creator tier cap of 5. Existing policies keep working. Creating new ones is blocked until you upgrade to Pro." The banner appears per-product (not page-wide) since the cap is per-product. Other products on the same instance show their own state independently.', '', '**Implementation note.** Reuses the v0.2.0:31 helpers (`capPreCheckCard`, `grandfatherBanner`) by synthesizing a `tierStatus` shape with `caps.policies` mapped to the per-product cap — no new component code needed, just an extra parameter threaded through `renderPolicyCardGrid` → `renderDraftTierCard`.', '', '**Test count: 87** (unchanged — pure UI).', '', '**Upgrade path.** v0.2.0:31 → v0.2.0:32 is a drop-in. No schema, no SDK breaking change.', '', '0.2.0:31 — **Four-item punchlist landed: cap-hit pre-check, grandfather banner, webhooks empty state, help-icon overhaul.** Clears the remaining outstanding admin-UI items.', '', '**Cap-hit pre-check (item #7).** Operators no longer have to submit-and-bounce off a 402 to learn they\'re about to hit a tier cap. The Products page and the Discount Codes page each call `/v1/admin/tier` on render and surface a gold-bordered "Approaching cap" warning inline above the create-form submit button whenever usage is at cap-1 (e.g. 4/5 products on Creator). The warning includes a direct upgrade link. The existing 402 → upgrade modal still fires if the operator goes ahead and submits.', '', '**Grandfather banner (item #6).** When the operator downgrades a tier and ends up with more existing rows than the new tier\'s cap allows (e.g. 8 products under Creator\'s 5-product cap), the relevant page now renders a persistent grandfather banner at the top: "Grandfathered: 8 products active vs Creator tier cap of 5. Existing products keep working. Creating new ones is blocked until you upgrade to Pro." The daemon\'s enforcement was already correct — it only blocks NEW writes, never deletes existing — but the UI was silent about it, leaving operators confused about why creates failed. Banner appears on Products + Discount Codes pages (the two surfaces with global tier caps). Per-product policy caps not yet pre-checked; that\'s a follow-up polish.', '', '**Webhooks empty state (item #10/15).** Previously the Webhooks tab rendered a bare "No webhooks registered." empty table on a fresh instance — no CTA, no context. Now there\'s a centered card with eyebrow + headline ("Get notified when something happens"), a 2-sentence "what\'s a webhook for?" explainer covering common use cases (license issued, code redeemed, invoice settled, fulfillment automation), and a primary "Add your first webhook" button that opens the create disclosure and focuses the URL input. Mirrors the Machines tab\'s empty state for visual consistency.', '', '**Help-icon click-to-toggle (item #13).** The "?" tooltips peppered through the admin UI previously used the browser\'s native `title=` attribute — hover-only, browser-styled, no keyboard access, accidentally triggered on grazes. Refactored to a small outlined `