1a14b9c2e37c3ffb89297f6c451d163202482b10
95 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1a14b9c2e3 |
v0.2.0:39 — Buy page: render tier card for single-public-policy products
Previously the tier picker gated on `policies.len() < 2` and returned an empty string when a product had only one public policy. Buyers saw just the price card + form — none of the entitlements, marketing bullets, or description the operator had carefully authored on that tier. Reported against the Recap product, which has 3 policies but only Pro public; Pro's bullets were invisible to buyers. Fixed: - render_tier_picker gate flipped from `< 2` to `is_empty()`. A single public policy now renders a single tier card. - New `.tiers-1` grid class: one centered column at ~480px max-width. Keeps the single card from stretching to the full 1040px container. - `n` computation extends to handle 1 in the existing match arm. The price card below the picker still renders unchanged for the single-policy case — acts as the buy-confirmation summary. Operators keeping most tiers private and only exposing one to buyers now get the same rich tier-card render that multi-tier products always had. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5c7d66dbb2 |
v0.2.0:38 — Create-product Cancel button + modal overflow fix
Two operator-reported bugs: 1. Create product had no Cancel. Added a secondary Cancel button next to "Create product" — collapses the disclosure without clearing typed input. 2. Edit product modal could grow taller than the viewport when the entitlements catalog had many entries, with no way to scroll. Cause: the modal card lacked max-height + overflow-y. Fixed Edit product specifically, then defensively swept every other dialog card in the admin UI for the same gap. 8 cards that were missing max-height got `max-height:90vh; overflow-y:auto` appended to their style block. Cards that already had the fix (Edit policy, Edit discount code) untouched. 11 modal cards now consistent: tier-cap upgrade, force-delete confirm, value-prompt, generic-confirm, license-issued display, BTCPay-connect, scoped-API-key generate, scoped-API-key show-once, edit-product, edit-policy, edit-discount-code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
11e30ffb21 |
LICENSE: tighten Keysat Source-Available License to v1.0
Custom source-available license, sharpened against several
ambiguities flagged in license review. Substance unchanged from
intent: operators can audit, run, modify, and sell licenses for
their own products through an instance they operate; reselling
the software itself or running it as a managed service for third
parties is forbidden. SDKs remain MIT in their own repos.
Concrete edits versus the prior draft:
- Added SPDX-Short-Identifier line (LicenseRef-Keysat-1.0) so
package metadata scanners can reference a stable name.
- New Section 1 (Definitions): "the Software", "You", "Internal
Use", "Modifications", "Managed Service" — closes the "is a
rented VPS internal use?" / "is publicly hosting on Tor a
managed service?" gaps.
- Internal Use explicitly includes rented compute. Managed
Service explicitly carves out "your own customers receiving
signed license keys" so the intended buy-page flow can never
be misread as forbidden.
- Section 3 (Restrictions) reworded for clarity:
- (a) "distribute compiled binaries" rather than ambiguous
"redistribute the source code" — source forks for audit /
contribution remain permitted under the trailing paragraph.
- (b) "provide ... as a Managed Service" — clean Elastic-v2
style phrasing in place of "publicly host a copy that is
accessible to or operable by parties other than yourself."
- Public source-code forks on GitHub explicitly permitted.
- Section 4 (Contributions): clarified as a non-exclusive
license-back, not a copyright assignment. Contributors retain
ownership.
- Section 6 (Termination): added "destroy or permanently delete
all copies" on termination + named the survival sections.
- Section 7 (Entire Agreement): added.
For commercial redistribution, resale, or hosted-service rights,
operators still email licensing@keysat.xyz — that's now
explicitly the single contact point at the bottom of the file.
|
||
|
|
487b5c2efa |
KEYSAT_INTEGRATION.md: collapse install paths to registries + add Go section
All four SDKs are now published to their registries: - npm: @keysat/licensing-client - crates.io: keysat-licensing-client - PyPI: keysat-licensing-client - Go module proxy: github.com/keysat-xyz/keysat-client-go Changes: - §7a / §7b / §7c install blocks collapsed from "Install (preferred) / GitHub fallback" pairs to single registry-install lines. The ssh-vs-https / prepare-script troubleshooting is no longer relevant for the install path. - New §7d: Go integration. Same shape as the other languages: install snippet, embed-pubkey pattern, verify-on-startup, use-at-feature-gate. Uses the Go SDK's IsTrial() method (not manual flag math). hex.EncodeToString for the LicenseID byte array. - Existing §7d (Hard-gate patterns), §7e (Packaging gotchas), §7f (Frontend integration) renumbered to §7e / §7f / §7g. - Cross-references updated everywhere (§0, §6, §15). - Header line updated: doc now claims Go support alongside the existing three languages. |
||
|
|
6201a30353 |
KEYSAT_INTEGRATION.md: fix bugs + refresh against current SDKs
Critical bug fixes — code an LLM would copy verbatim: - Wire format §4: clarify FLAG_FINGERPRINT_BOUND = bit 0 (mask 0x01), FLAG_TRIAL = bit 1 (mask 0x02). The doc previously claimed FLAG_TRIAL=1, which is wrong — that's the fingerprint-bound bit. - Trial detection across §7a / §7b / §7c / §14: stop doing `(flags & 1)` manually. TS/Python/Go SDKs pre-parse isTrial / is_trial / IsTrial() on the payload. Rust requires manual math but with the FLAG_TRIAL constant from the crate, not bit 0. - Field name sweep: TS payload field is `licenseUuid` (or top- level `licenseId` on the result root), not `payload.licenseId`. Rust payload's `license_id` is `[u8; 16]` raw bytes — render to hex for display. Updated examples to match each SDK's actual API. - §9a cross-product safety: rewritten end-to-end. The payload carries product UUID (not slug). The old doc told the LLM to assert `payload.product_slug !== MY_SLUG`, which silently passes because the field doesn't exist. New doc covers both correct paths: online via `validate(slug, …)` (daemon resolves slug→UUID), or offline by embedding the operator's product UUID. Stale references / improvements: - §0 Q4: cross-reference for hard-gate flavors corrected to §7d (was pointing at §8 which is entitlement-naming). Added a "soft-gate is the safe default" nudge. - §0 new Q8: ask whether the operator already has an entitlements catalog before drafting the config card. - §7a GitHub fallback: trimmed. SDK repos are public and have `prepare` scripts, so the ssh-vs-https troubleshooting saga isn't needed anymore. - §7d "Mode::Enforce" reference removed — that build-time flag was deprecated. Keysat itself dogfoods soft-gate (always boots, tier caps enforce at create-time). New content for one-shot integration success: - §8 / §11a: hidden_entitlements (v0.2.0:24) explained — buy page filters them out; SDK consumers should too. - §11a "Rendering tier cards": multi-currency formatter (priceCurrency + priceValue), marketing_bullets + marketing_bullets_position, featured_discount auto-apply via the `code` option on startPurchase. - §11a Common mistakes: assuming all prices are in sats; skipping the featured-discount surfacing. - New §15a "Verify your integration with curl": four-command health-check the LLM can run before writing app code. Catches slug typos, missing policies, unreachable daemon early. - §15 Common mistakes: added the product UUID gotcha and the flag bit-math gotcha as explicit entries. LLM-consumer impact: the previous version had three subtle bugs that survived offline signature verification — wrong trial detection on every fingerprint-bound license, missing product isolation across multi-product Keysats, and a wrong PRO-tier default selection. All three failure modes are now flagged or fixed in the doc; an LLM that follows the new doc literally produces correct integration code. |
||
|
|
9e772fdd4c |
README: add developer Quick-start; refresh stale sections
- New "Quick start" block at top — pitch line matches the keysat.xyz hero, links to docs.keysat.xyz, and lists the four SDK install commands (TS / Rust / Python / Go) so a GitHub visitor sees the install path before the StartOS-package internals. - Tagline updated to drop "Bitcoin-native" in favor of "payment channels you control" (matches the landing-page de-emphasis). - First-run flow step 6: drop the "default slug is canonical" myth. Multi-tier ladders are first-class. - Actions list: drop npub from license search (UI surfaces only email + invoice id; backend supports npub but the purchase flow hasn't shipped buyer npub capture yet). - Limitations section: drop "no recurring subscriptions" and "no tier upgrades" — both shipped in v0.2. Add Zaprite-pending caveat. - YAML quick-reference: expand the features block to reflect the current daemon (multi-currency, marketing bullets, hidden entitlements, featured discounts, multi-policy scope, recurring subs, tier upgrades, webhook DLQ, scoped API keys, OpenAPI spec, Creator/Pro/Patron self-licensing tiers). Add SDK list. |
||
|
|
aaf8bddfe4 |
v0.2.0:37 — "Limited" → "Limited discount" on launch-special meta
Adds the word "discount" so buyers don't misread the limit as a license count. "Limited: 10 remaining" was ambiguous; "Limited discount: 10 remaining" is unmistakable. Landing-page dynamic tier-card JS matches in a separate commit on the keysat-xyz-landing repo. Cosmetic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e05d357a5a |
v0.2.0:36 — Launch-special remaining: "N remaining", drop the total
Buy-page tier card's "Limited: N of M remaining" line now reads just "Limited: N remaining". The total cap (M) is operator-private — there's no upside to exposing initial launch volume to buyers, and it can make a tier feel smaller than the operator intends. Symmetric landing-page change (index.html dynamic tier-card JS) ships alongside in a separate commit on the keysat-xyz-landing repo. Cosmetic; no API or schema change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a0995c9c31 |
v0.2.0:35 — Free tiers render as "Free" on the buy-page tier card
Previously the server rendered a 0-priced tier as "0 sats" (or
"0.00 USD") in the tier-card headline. The price card below the
tier picker already swapped to "FREE" via the JS path, so the
two surfaces disagreed.
Now: when post-discount price is 0, the tier card renders the
headline as "Free" with no unit suffix and no cadence-suffix
("Free /yr" would be incoherent). recurring_meta ("Renews
annually") still surfaces beneath for recurring-free edge cases,
so cadence isn't lost — just not stuffed into the headline.
Cosmetic; no API or schema change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
6c8df98cfd |
v0.2.0:34 — Buy page: pre-populate featured code in discount input
Previously a tier's featured (launch-special) discount auto-applied silently at payment time but the discount-code input was empty, leaving buyers unsure whether they needed to type anything to claim the slashed price. Now: when a tier has an active featured discount, selectTier() pre-fills codeInput with the code string and flips into the "applied" state — appliedCode set, status badge shows "Launch special applied". The price card has always rendered the struck-original + discounted-current price; this change just makes the form match what's already visually claimed. New `autoAppliedFeatured` flag distinguishes auto-populated codes from buyer-typed ones: - On tier switch, the reset block also clears the input when autoAppliedFeatured was true (the prior featured code doesn't necessarily apply to the new tier; better to start fresh). - Buyer-typed codes are NOT cleared on tier switch — they may be valid for the new tier, and the buyer can hit Apply to check. - Any keystroke in codeInput, or a successful manual Apply, flips the flag to false. JS / template only; no API or schema change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
752beff429 |
v0.2.0:33 — Drop unused invoice_id_safe warning
`let invoice_id_safe = html_escape(&invoice_id);` in api::thank_you was computed but never referenced — the template uses invoice_id_json for the inline JS, and the visible invoice id renders from that JSON via JS. One-line removal; cargo check now warning-free. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
70ce20951b |
v0.2.0:32 — Per-product policy cap pre-check + grandfather banner
Closes the third tier-enforced surface (Creator caps policies at 5 per product). Same UX shape as the global products + codes pre-check in v0.2.0:31, scoped to a single product instead of the whole instance. - routes.policies fetches /v1/admin/tier once on render and threads the status into renderPolicyCardGrid. - renderPolicyCardGrid renders a grandfather banner above the tier grid when policies.length > caps.policies_per_product (per- product, since the cap is per-product). - renderDraftTierCard accepts (tierStatus, productPolicyCount) and shows the same pre-check warning at the top of the draft form when used == cap - 1 (approaching) or used >= cap (over). - Reuses existing helpers (capPreCheckCard, grandfatherBanner) by synthesizing a tierStatus shape with caps.policies mapped to the per-product cap. No new component code. UI-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3d7cf166db |
v0.2.0:31 — Punchlist clear: cap pre-check, grandfather banner, webhooks empty state, help-icon overhaul
Four outstanding admin-UI items shipped: - Cap-hit pre-check. Products + Discount Codes pages fetch /v1/admin/tier on render and inline a gold-bordered "Approaching cap" warning above the submit button when usage is at cap-1. Includes a direct upgrade link. The existing 402 modal still fires if the operator submits anyway. - Grandfather banner. When usage > current tier cap (e.g. downgrade from Pro to Creator with 8 products under a 5-product cap), the relevant page renders a persistent banner explaining the grandfather state and that new creates are blocked until upgrade. The daemon enforcement was already correct; the UI was silent. - Webhooks empty state. Replaced the bare "No webhooks registered." table with a centered CTA card: eyebrow, headline, 2-sentence explainer of what webhooks are good for, and a primary "Add your first webhook" button that opens the create disclosure + focuses the URL input. Mirrors the Machines empty state. - Help-icon click-to-toggle. helpIcon() now renders a small outlined button that opens a navy popover anchored next to it on click. Click outside / Esc / click again closes. Focus + Enter / Space opens. Visually less prominent. Replaces the prior native title= hover tooltip. Single function used everywhere, so the refactor ripples across the whole admin. Three reusable helpers added: loadTierStatus, capPreCheckCard, grandfatherBanner. UI-only. No schema, API, or SDK change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
52deb82ad2 |
v0.2.0:30 — Two copy fixes: pubkey tip + Licenses search
- "Embed your public key" tip now says "your product's source code" instead of "your app's source" — clearer for operators distributing libraries, services, or anything that isn't an app. - Licenses search row: dropped Nostr npub from the placeholder, the description text, and the search-field dropdown. The purchase flow doesn't capture buyer npubs yet, so the option had nothing to find. Backend search-by-npub path is untouched — re-expose the UI option once buyer npub capture lands in the purchase flow. UI copy only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1bd1bde895 |
v0.2.0:29 — Tier-card cross-card horizontal alignment via subgrid
Visually equivalent sections of each tier card (names, prices, first feature bullet, Select button) now line up horizontally across all visible tier cards. Cards with fewer / shorter sections get extra whitespace in the rows they don't fill — the explicit tradeoff the operator asked for, in service of a cleaner grid. - .tiers parent grid now declares 8 explicit row tracks. Each .tier is a subgrid that shares those rows. - Each section class (.tier-launch-meta, .tier-name, .tier-price- original, .tier-price, .tier-meta-block, .tier-description, .tier-features, .tier-select-btn) gets an explicit grid-row. Missing sections leave the row empty without breaking alignment. - Meta lines (duration, recurring, trial banner, trial flag) now wrapped in a single .tier-meta-block so they land in one row as a flex-column. - Launch-meta separated from featured_ribbon so each can occupy its own grid row independently (vs. the ribbon string previously embedding the meta div in-flow). - Side fix: .tier.has-launch swapped from overflow:hidden to clip-path polygon that preserves 20px above the card. The popular pill returns to top:-10px (above the card) without being clipped. Removed the v0.2.0:26-27 padding-top:36px workaround that pushed the pill inside. CSS + HTML composition only; public API JSON unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
559e657b90 |
v0.2.0:28 — Settings polish, operator-name fix, Hide-revoked toggle
Three small admin-UI fixes: - Settings page intro card removed. The preamble was redundant with the page title + section headers. - Operator-name save no longer 404s. The JS was POSTing to /v1/admin/operator-name; the daemon mounts the endpoint at /v1/admin/settings/operator-name. Fixed both GET and POST paths. - Licenses page: pill toggle "Hide revoked" between the product filter row and stat cards. Filters rendered rows; stat cards still show the true revoked count so operators don't lose visibility. UI-only; no schema, API, or SDK change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4377dfbb34 |
v0.2.0:27 — Single tier-features ul; popular pill spacing fix
Two remaining buy-page issues from :26: - Tier-card feature list. Stop fighting the two-<ul> boundary with margin tweaks. Build ONE <ul class="tier-features"> server-side containing marketing bullets and entitlements in the operator- controlled order. Both groups render with identical ✓ + li styling, visually indistinguishable to the buyer. No list boundary = no gap. - "MOST POPULAR" + "Limited: ..." collision. The :26 fix moved the popular pill to top:8px (inside the card) for has-launch tiers, but that landed it on top of the launch-meta line. Push the card content down via padding-top:36px on .tier.has-launch.highlighted (35px when also .selected to compensate for the thicker border). CSS + HTML composition only; no schema, API, or SDK change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9628001f69 |
v0.2.0:26 — Buy-page + entitlement-picker visual polish
Cluster of small visual fixes: - Tier-card feature list seam. Zeroed margin-top between adjacent marketing-bullet + entitlement lists in either order so the gap between lists matches the within-list gap. Reads as one column. - MOST POPULAR clip. When a tier was both highlighted AND had a launch ribbon, overflow:hidden (for the ribbon overhang) was also clipping the popular pill that floats above the card. Pill drops to top:8px (inside the card) only for the highlighted + has-launch combination. - Price card width. :23 stretched the price card to 1040px alongside the headline; that overpowered everything below the tier picker. Constrained back to 560px (centered); headline stays full-width. - Entitlement bubble picker theme. Selected chips switch from gold- filled to navy-filled with cream text (matches "Selected" tier- select-btn + Featured-ON toggle). Hidden-on-buy state drops the strikethrough — opacity:0.5 on the whole pill is the signal. - Discount-code policy multi-pickers follow the same navy theme on Create + Edit (re-aligned from the brief gold pass in :25). - Admin Policies grid also drops strikethrough on hidden chips; opacity-only, italic "(hidden on buy)" hint stays. CSS + inline-style only; no data, schema, or API change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f4861eec44 |
v0.2.0:25 — Match discount-code policy pills to admin palette
The "Restrict to policies" multi-pickers (Create + Edit forms) were rendering selected pills as dark navy with gold text — visually off-key against the gold-filled / cream-outlined pill convention used elsewhere in the admin (entitlement bubbles, marketing-bullets position, etc.). Aligned both pickers to the shared style. Cosmetic only; no data or behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
033a1f4a6a |
v0.2.0:24 — Per-entitlement "hide on buy page" toggle
Decouples "what the license grants" from "what the buyer sees on the tier card." Operator can mark individual entitlements as hidden from the buy page tier-card display; the issued license still carries them. Enables the "Everything in Creator, plus:" marketing pattern without duplicating implied entitlements on higher-tier cards. - entitlementBubblePicker accepts a third `initialHidden` param and exposes a `readHidden()` method alongside `read()`. Each granted chip gets a small eye toggle (👁 visible / 👁🗨 hidden). Click chip name = grant/revoke. Click eye = hide-on-buy toggle. De-selecting a chip clears its hidden state automatically. - New per-policy metadata: hidden_entitlements: string[]. Buy page filters before rendering tier-card entitlement chips. Public /v1/products/<slug>/policies exposes the array so SDKs and dynamic pricing pages stay in sync. - Admin Policies grid still shows ALL entitlements (operator-truth view) but hidden ones get muted opacity + strikethrough + a small "(hidden on buy)" italic hint. No schema change; pure metadata pass-through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0e46ce399d |
v0.2.0:23 — Buy-page polish: width balance, auto-discount, bullet gap
Three concrete fixes after :21 rolled the wider buy page:
- Layout proportions. Headline + price card span the full 1040px
container with center-aligned text (matches the tier picker
width). Only the email/discount/pay form stays narrow at 560px
since input fields look stretched at 1040px.
- Featured discount auto-applies on the headline price. Tier JSON
now carries each tier's featured-discount snapshot, and the JS
selectTier() renders strike-through + discounted price when an
active featured code applies. Tier switching also re-applies the
featured code for the new tier instead of resetting to base.
- Marketing-bullets gap. Added mirror CSS rule
`.tier-entitlements + .tier-bullets { margin-top:2px }` so the
bullets-below layout has the same tight visual continuity that
bullets-above already had.
Public buy-page CSS + JS only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
3c054c65db |
v0.2.0:22 — Policy scope is editable on discount codes
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> |
||
|
|
6fd7dd9302 |
v0.2.0:21 — Wider buy page (1040px) so 3-tier grids breathe
The public /buy/<slug> page was capped at 560px. With three tier cards side-by-side that made everything narrow and tall in a desktop browser. Bumped the outer container to 1040px so the tier picker matches the admin Policies page layout. - .wrap max-width: 560px → 1040px. - .wrap > :not(.tiers) max-width:560px + margin-auto so the form, price card, and intro text stay centered at reading width below the wider tier picker. - .topbar .inner widened 680px → 1040px to align with .wrap. - .eyebrow display:inline-flex → flex + width:fit-content so the margin:auto centering rule applies. Mobile breakpoints unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
094cf75e52 |
v0.2.0:20 — Multi-policy scope for discount codes
A discount code can now apply to a subset of policies on a product (e.g. "Patron and Pro but not Creator") instead of being limited to exactly one policy or the entire product. - Migration 0018 adds `applies_to_policy_ids_json` (nullable JSON array of policy ids). Legacy `applies_to_policy_id` stays as the singular fallback when the JSON column is empty/NULL. - `DiscountCode::allowed_policy_ids()` helper unifies multi + singular into one Vec. Purchase + preview scope checks consult it. - `find_applicable_featured_discount` now narrows multi-policy candidates in Rust (small candidate set; index-friendly SQL would require json_each, deferred). - Admin API: `POST /v1/admin/discount-codes` accepts `policy_slugs` (array) alongside the existing `policy_slug` (singular). Multi wins when both are present. PATCH does not allow scope edits — same rule as the singular field (disable + recreate to re-scope). - UI: pill multi-select replaces the policy dropdown on the create form. Edit modal's scope label renders the comma-separated list. UI + schema both back-compat: existing codes keep working unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
eb360a325e |
v0.2.0:19 — Marketing bullets: choose above or below entitlements
Operator picks where the free-form ✓ checkmark copy renders on each
tier card. Default "above" matches prior behavior; "below" is opt-in
per policy.
- New metadata field metadata.marketing_bullets_position ("above" |
"below"). Persisted only when bullets exist AND choice != default.
- UI: select next to the bullets textarea on create + edit forms.
- Admin grid: swaps marketingList + entChips order accordingly,
including the top-margin tighten-up so the lists hug each other.
- Buy page (buy_page.rs): swaps marketing_html + entitlements_html in
the tier-card template via destructured (first, second) tuple.
- Public /v1/products/<slug>/policies: exposes the position field as
"above" | "below" (normalized) so SDK consumers stay in sync.
UI-only/metadata-only; no schema, no SDK breaking change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
bb53d708a1 |
v0.2.0:18 — Discount Codes form polish
Three small admin-UI changes that make the create + edit forms less footgun-prone: - Max-uses: "Limit total uses" checkbox + dependent number input (default 100), replacing the "0 = unlimited" pattern that read like "0 uses allowed." Unchecked sends no cap. - Currency dropdown hides for percent + free_license kinds (neither has a currency). Stays for fixed_amount. - Featured flag promoted from buried checkbox to a prominent gold pill toggle. Edit form starts in correct state. UI-only; no schema, no SDK, no behavior change for buyers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
11cf1808c6 |
v0.2.0:17 — Discount Codes form usability
Three improvements to the Discount Codes tab: 1. Scope pickers replace text inputs. The create form's 'Restrict to product slug' free-text input is now a dropdown populated from /v1/products. A dependent 'Restrict to policy' dropdown loads policies for the selected product on the fly. Both default to 'Any' so the no-scope global-code behavior is preserved. 2. datetime-local picker on expires_at. Native calendar + time spinner on both create + edit forms. Submit converts back to RFC3339 UTC automatically. Empty = no expiry. 3. Edit form shows scope read-only. 'Applies to: [product] -> [policy]' (or 'all products on this instance' for global codes) renders as a muted info block at the top. Scope remains immutable (disable + create new to change). routes.codes now pre-fetches /v1/products once at the top (reused for both the create form scope pickers and the per- product table grouping). No more duplicate fetch. UI-only release. |
||
|
|
4334a9f044 |
v0.2.0:16 — Launch-special discount codes + marketing bullets
Major feature release.
Featured (launch-special) discount codes:
- New 'featured' flag on discount_codes (migration 0017). When true,
the buy page renders a diagonal LAUNCH SPECIAL ribbon + slashed
original price + new price for every applicable tier. Purchase
endpoint auto-applies the discount for buyers who don't type a
code. Operator-typed codes still win.
- find_applicable_featured_discount repo helper: most-specific match
(policy > product > global), tiebreak by created_at.
- GET /v1/products/<slug>/policies now returns featured_discount per
policy with the post-discount price computed server-side. SDK
consumers + the dynamic pricing page get this for free.
Marketing bullets on policies:
- metadata.marketing_bullets — operator-controlled copy that renders
as additional checkmarks above the entitlement bullets on both the
admin grid tier card and the buy page tier. For things like 'Up
to 5 products' or 'BTCPay integration' that aren't real
entitlement gates.
- Authored via textarea on draft + edit policy forms.
UI:
- 'Most popular' checkbox now on the draft tier card (was edit-only).
- Discount codes tab grouped by product (matching Licenses /
Subscriptions tabs). Each code row gets a 'featured' badge when
flagged.
All 87 tests still pass. Migration is additive, no SDK changes,
backwards-compatible.
|
||
|
|
2789d1da1f |
v0.2.0:15 — Multi-draft tier authoring + custom durations on draft cards
Two papercut fixes for the policy create flow: 1. Multi-draft survival. Previously, committing one draft tier card triggered a full grid reload via onMutate(), wiping any sibling drafts the operator had open. Now the commit callback receives the saved policy and replaces ONLY that draft's grid slot with a finalized tier card — sibling drafts keep their input state intact. Author Creator / Pro / Patron in parallel and click Create on each as it's ready, in any order. 2. Custom duration on draft cards. The Duration dropdown gains a "Custom (days)" option at the bottom; selecting it reveals a number input. On submit, days * 86400 = seconds is what gets sent. Matches the Edit-policy modal's existing custom pattern (which is in raw seconds); the draft uses days because day-based input is friendlier for the cadences operators actually pick. UI-only release. No daemon code changes, no schema. |
||
|
|
519fa1a8e6 |
v0.2.0:14 — Entitlements catalog read fix + drag-and-drop tier ordering
Bug fix:
Product entitlements catalog reads were silently dropping. Every
SELECT against the products table was missing entitlements_catalog_json
from the column list, so the PATCH handler wrote the catalog correctly
but every subsequent read returned null. Admin UI edits appeared to
vanish on save. Fix: added the column to all four product SELECTs
in repo.rs (list_products, get_product_by_slug, get_product_by_id —
one column list, replace_all). Added regression test
product_entitlements_catalog_round_trips_through_list_endpoint that
exercises the full PATCH → list round-trip the admin UI hits.
UX:
Drag-and-drop reordering on the tier-card grid. Operator drags any
tier card to a new position; on drop, parallel PATCH requests set
tier_rank 1..N based on the new visual order. Archived tiers are
excluded (their position in the ladder is moot). Edit-policy modal
retains the tier_rank number field for the two cases drag-and-drop
can't express (precise override + blank-to-remove-from-ladder).
Cursor signals grab/grabbing on hover/drag; dragging card lifts +
fades for visual feedback.
Copy:
Policies-tab section headers now show just the product name
("Keysat") instead of redundant "Keysat — keysat". Entitlements-
catalog row editor description placeholder shortened from
"Description (shown on buy page tooltip)" to "Description (buyer
tooltip)" so it fits the column; full hover hint kept on the
input's title attribute.
Test count: 87.
|
||
|
|
76fe7fe6b9 |
v0.2.0:13 — CORS on public endpoints
Adds tower-http CorsLayer at the outermost router position so: - Browsers can fetch /v1/products/<slug>/policies, /v1/openapi.json, /v1/issuer/public-key, /v1/validate from any origin. Unblocks the dynamic pricing page on docs.keysat.xyz reading live tier config from licensing.keysat.xyz. - Preflight OPTIONS is handled by the CorsLayer directly, never reaches the session-bridge or any handler — so admin endpoints don't 401 on preflight. Security posture unchanged. Access-Control-Allow-Credentials is OFF. The combination of ACAO=* and no-credentials means a cross-origin page can read public responses but can't ride a logged-in admin session cookie to hit /v1/admin/*. Admin endpoints still require an explicit Bearer token, which browsers don't auto-attach cross-origin. Tests: +2 CORS regression tests (cors_allows_cross_origin_on_public_ endpoints, cors_preflight_returns_2xx_without_auth). Full suite: 85 passing. |
||
|
|
257669092b |
v0.2.0:11 + v0.2.0:12 — Archive, Settings, agent surface, machines redesign
Two release cycles prepared together: v0.2.0:11 (policy archive + safe- delete cleanup + brand-consistent confirm modals) and v0.2.0:12 (Settings tab + agent-friendly operator API + machines tab redesign + buyer-facing copy alignment). Highlights: - Migration 0015: policies.archived_at column. Archive button on tier cards; safe-delete relaxed to ignore revoked-license tombstones; renewal worker refuses archived policies. - Migration 0016: scoped_api_keys table. Four roles (read-only, license-issuer, support, full-admin) with bounded scopes. Master admin_api_key still works on every endpoint; scoped keys gated on endpoints wired through require_scope(). - New /v1/openapi.json — public, no auth. Curated OpenAPI 3.1 spec for agent / SDK discovery. - New Settings tab: Operator name + Payment providers panel + API keys management. Replaces 8 StartOS Actions (Zaprite all, BTCPay all, operator name, switch-provider). StartOS Actions pruned to 4 install-time essentials. - Machines tab rewritten: global default view grouped by product, filter pills with counts, quick-stats row, drill-down via new "Machines" button on each Licenses-tab row. New repo helper list_machines_admin joins machines x licenses x products server-side. - Branded confirmModal replaces every native window.confirm() call in the admin UI (7 callsites). - Enforce mode killed: KEYSAT_LICENSE_ENFORCE compile-time flag retired; daemon always boots; missing self-license -> Creator (free) tier. "Unlicensed" label gone from admin UI. - Zaprite gated on the new zaprite_payments entitlement (renamed from card_payments to reflect the broader gateway). - Creator code cap 5 -> 10. - KEYSAT_AGENT_GUIDE.md: auth, role-to-scope mapping, error envelope, webhook events, worked recipes. - Buyer-facing copy aligned with new positioning: "Bitcoin-native self-hosted software licensing" everywhere on production surfaces. - Cross-product safety section (Section 9a) added to KEYSAT_INTEGRATION.md. - 5 new API integration smoke tests covering OpenAPI, scoped API keys CRUD, role-elevation guard, and Zaprite-tier gating. Test count: 83 passing (was 78). All migration tests pass against 0015 and 0016 applied to populated DBs. |
||
|
|
20b5293c81 |
v0.2.0:10 — Licenses + Subscriptions tabs reorganized by product
Both tabs now group by product (matching the per-product card
sections in Products + Policies), with product-filter pills + per-
product counts at the top. Multi-product instances see one section
per product with a status breakdown subtitle ("3 active · 1
revoked · 2 expired"); single-product instances continue to see a
flat table with no chrome overhead. Search results bypass grouping
(search is global across all products).
Three new shared helpers added at the top of the script:
- clickToCopy(fullValue, displayLabel) — clickable code element
that copies the full ID to clipboard with a "✓ copied"
indicator. Replaces the older hover-to-see-full-id UX for
license / subscription IDs.
- relativeDate(rfc3339, opts) — renders an RFC3339 timestamp as
a human-relative string ("in 3 days" / "12 hours ago") with
the absolute timestamp in a hover tooltip. Applied to license
issued/expires + subscription next_renewal.
- reasonModal({title, message, warning, confirmLabel,
confirmVariant}) — inline overlay-card replacement for the
native prompt() / confirm() dialogs. Used by:
* Subscription cancellation flow
* License suspend / unsuspend / revoke flows
Same UX language as the Change Tier modal.
Subscriptions tab specifics:
- Product filter pills with per-product counts (filtered by
active status filter so the counts reflect what the operator
is currently viewing).
- Status filter pills gain counts (Active (3), Past due (0), etc.)
- New Product column shows display name + slug.
- Status badges have hover tooltips explaining each state's meaning.
- Cancel button uses reasonModal instead of prompt().
Licenses tab specifics:
- Quick-stats row: Licenses / Active / Revoked / Expiring < 30d.
Scope follows the active product filter; hover "?" icons
define each stat. Mirrors the Overview dashboard style.
- Search affordance preserved; search results render as a single
flat table titled "Search results" (not grouped by product).
- Manual-issue form's hint blocks replaced with help icons on
every field. Compact-form treatment to match Products + Policies.
- Suspend / unsuspend / revoke buttons use reasonModal with
per-action context (irreversible warning on revoke, etc.)
instead of confirm() + prompt() double-dialog.
- Entitlements rendered with display name primary + description
tooltip (resolves against the product's catalog from
/v1/products's response).
Pure UI release. 78/78 tests still pass. No schema, SDK, or
behavior change.
|
||
|
|
0ea3469899 |
v0.2.0:9 — side-by-side tier-card policy authoring + form polish
The Policies tab gets the redesign Grant asked for: replace the table view + verbose disclosure form with a card grid where each existing policy renders as a buy-page-style tier card sitting next to a dashed "+ Add tier" placeholder. Click the placeholder, it morphs into an editable draft tier card with inline form fields; submit Create on the card and it flips into a read-only preview. Multiple drafts can coexist for parallel multi-tier authoring with side-by-side comparison. New JS helpers: - helpIcon(text) — small "?" hover tooltip for compact form labels - slugify(s) — URL-safe slug derivation from display name - renderTierCard(pol, product, onMutate) — read-only buy-page-style preview card with Edit / Hide-Show / Delete actions - renderAddTierCard(onClick) — dashed placeholder with "+" affordance - renderDraftTierCard(product, onCommit, onCancel) — inline editable card with name + slug + price + duration + entitlement bubble picker + recurring/trial toggles - renderPolicyCardGrid(product, policies, byPolicyCounts, onMutate) — ties them together. Submitting "+ Add tier" appends a fresh placeholder, so operators can keep clicking to author multiple tiers in one session. formInput() upgraded: - New `help:` option renders a helpIcon next to the label (replaces verbose hint text under the input) - New `placeholder:` option for cleaner empty-state cues Auto-slug: - Product create form's Display name field mirrors a slugified version into the Slug field as the operator types — until they manually edit the slug, which arms a "userOverridden" guard so manual edits stick. Re-arms when the slug field is cleared. Legacy "Create a new policy" disclosure form unsurfaced from the Policies route — the card grid replaces it. Advanced fields (custom grace seconds, tip recipient, tier rank) still live on the existing Edit modal of an already-committed tier card. Power-user flow: card grid creates the basics, Edit modal refines. Test count unchanged (78). UI-only release. |
||
|
|
4b9ef0ea8c |
v0.2.0:8 release notes + integration doc section 8 update
Notes cover the entitlements catalog feature shipped in
|
||
|
|
68dfe7f6fc |
Product entitlements catalog (Phase 1: schema + admin + buy page)
Closes the request to make entitlements first-class on products
instead of free-text strings on policies. Operators declare the
closed list of entitlements a product offers — slug + display name
+ optional description — and policies pick from that list with a
click-to-toggle bubble UI. Buy page renders human-readable names
("AI summaries") with descriptions as tooltips, never the raw slug
("ai_summaries").
Schema (migration 0014):
- products.entitlements_catalog_json: nullable JSON column shaped
as [{slug, name, description}, ...]
- Auto-backfill on upgrade: for each existing product, derive a
catalog from the union of its policies' entitlement slugs, with
name = slug.replace('_', ' ') and empty description. Operators
can refine afterward.
- Products with no policy entitlements stay NULL (legacy
free-text mode preserved).
Server:
- Product struct gains entitlements_catalog: Option<Vec<EntitlementDef>>
- repo::set_product_entitlements_catalog (validates lowercase ASCII
slugs, uniqueness, defaults name to slug if empty)
- Product create/update API accept entitlements_catalog;
update uses double-Option PATCH shape so operators can clear
- Closed-list validation: when product has a non-empty catalog,
policy create + update reject any entitlement slug not in the
catalog with a clear error pointing at the right path
- /v1/products/<slug>/policies surfaces entitlements_catalog
in the product object so SDK consumers can render display
names client-side
- Buy page renders entitlement display names + description tooltips
on tier cards (falls back to raw slug for legacy entries that
predate the catalog)
Admin UI:
- New catalogEditor() helper (repeating slug/name/description rows
with add/remove buttons) embedded in product create + edit forms
- New entitlementBubblePicker() helper (click-to-toggle pill chips
showing display name with description tooltip)
- Policy create form: entitlements input swaps based on the chosen
product's catalog — bubble picker when catalog has entries,
legacy textarea otherwise. Rebuilds when operator changes
product.
- Policy edit modal: same bubble-picker-or-textarea swap, scoped
to the policy's product
- Policy list table: entitlement column shows display names
(resolved against the product's catalog) instead of slugs
Migration regression test verifies:
- Backfill correctly unions entitlements across all of a product's
policies, deduplicates, applies name = slug-with-underscores-as-
spaces transformation
- Products with no policy entitlements get NULL (not [])
- Manually-set catalog values round-trip
- Schema is otherwise FK-clean post-migration
Test count: 78 (was 77; +1 for migration_0014_backfills_*).
Phase 2 (SDK updates + integration doc + side-by-side card-grid
policy authoring UI) ships in follow-up commits before v0.2.0:8.
|
||
|
|
b95b47e0d5 |
v0.2.0:7 — align package copy with website positioning
The Start9 registry card was still showing "Keysat — self-hosted Bitcoin-paid software license server" while keysat.xyz now leads with "Bitcoin-native self-hosted licensing service for software creators." Operators landing on the registry from the marketing site got a jarring tagline mismatch. Aligned everywhere the old copy was hardcoded: - startos/manifest/i18n.ts (short + long descriptions — these drive the registry card) - assets/ABOUT.md (in-StartOS About panel) - README.md (root + licensing-service/) - licensing-service/Cargo.toml description Long description also picked up two updates that should have landed when the features did but never made it into the marketing copy: - Zaprite mention (Bitcoin + cards) alongside BTCPay - Recurring subscriptions + in-place tier upgrades Pure copy change. No code, no behavior, no schema. Republishing as :7 because the registry card text lives inside the .s9pk and won't refresh on operators' boxes without a version bump. |
||
|
|
ad338d0c3d | fix: drop unsupported 'required' field on Value.select for switch action | ||
|
|
ff92ed0463 |
v0.2.0:6 release notes — recurring + trials + self-tier live refresh
Big release notes pass covering the testing-driven fixes from |
||
|
|
89d505b9de |
KEYSAT_INTEGRATION.md: section 0a "How enforcement actually works"
Captures the offline-vs-online enforcement framing that every operator hits when they realize they want to revoke / downgrade / lapse a license. Previously this answer was scattered across sections; consolidating into a dedicated section 0a so both LLMs and humans following the integration doc see it before they make the SDK call-pattern decision. Covers: - What the buyer's app can enforce offline (baked-in expiry, entitlement set, trial flag, fingerprint binding) - What the operator can change ONLY online (revocation, tier changes, sub lapses, seat enforcement) - The two design dials operators pick (baked-expiry length, whether the app calls validate()) - The two patterns: A = "true perpetual, offline-only"; B = "perpetual price, online-enforced entitlements" - Side-by-side TS code samples for each pattern - Operator-side implications for each product type (perpetual, recurring, trial-converting) - Cross-reference to section 11a (tier upgrades only have teeth with Pattern B) so the LLM following that section's flow back to here gets the right framing - Note that Keysat itself dogfoods Pattern B (with reference to the new license_self::refresh_self_tier_from_db helper) The framing is the same one that came out of Grant's testing session — the integration doc is now the canonical place to point any future operator who asks "wait, why doesn't downgrading take effect?" |
||
|
|
927ac2be53 |
UX polish — duration, preview button, Select state, dropdown current, switch action
Pure UX bundle from the testing batch. None individually changes behavior; together they remove a half-dozen sharp edges. 1. Policy-list duration column: human-readable `31536000s` / `604800s` / `0s` are now `1 year` / `1 week` / `perpetual`. New `fmtDuration()` helper handles common cadences (1 day, 1 week, 1 month, 3 months, 6 months, 1 year, 2 years) with arithmetic fallbacks for non-canonical values. Grace column gets the same treatment with "none" for 0. 2. "Preview buy page" button per product header The Policies tab's per-product card now has a "Preview buy page" button on the right side of the header (when ≥ 1 public+active policy exists). Opens /buy/<slug> in a new tab. tableCard() helper grew an optional headerAction param. 3. Buy page tier card: "Select" → "Selected" When a tier becomes the active selection, its button label flips to "Selected" while other tiers' buttons stay "Select". Combined with the existing .selected card-border styling gives buyers an unambiguous "yes, this tier is what's tied to the price card below" cue. 4. Licenses page POLICY column shows display name Was showing slug (`recurring`, `core`, `creator`); now shows the operator-set display name (Recurring Pro, Core, Creator) primary, with the slug as a smaller mono-font line below. Operators see what the buyer sees while keeping the slug visible for SDK reference. (Subscriptions tab already handled this pattern; this brings Licenses in line.) 5. Change Tier dropdown: "(current)" annotation Current tier now appears in the dropdown but with " · current" appended and `disabled` attribute set. Operator sees what they're starting from but can't pick the no-op. Auto-selects the first SELECTABLE option so the modal opens with a valid target ready. formSelect() helper grew per-option `disabled` support. 6. Single "Switch active payment provider" StartOS action The two old "Activate BTCPay" / "Activate Zaprite" actions collapsed into one dropdown-driven action. Operators saw the pair as confusing — both appeared alongside Connect / Disconnect / Status, and operators couldn't tell at a glance which one was currently active. New action pre-fills the dropdown with the currently-active provider so opening it is immediately informative. Old action ids retained as visibility:'hidden' shims for back-compat with any operator scripts pointing at them. Test count unchanged; UI-only changes don't touch any test fixtures. |
||
|
|
54f7ea08b5 |
P1 — change-tier UX, Zaprite webhook copy, self-tier guard, Lightning copy
Bundle of bugfixes from the P1 testing batch. None individually
huge; together they close several "tested it, hit a sharp edge"
items.
1. Change-tier modal — kill the paid path from UI
The Apply-as-comp toggle is gone. Admin tier changes always
apply as comp now. The reasoning (per Grant's testing): admin
tier changes are operator-driven, payment has either already
happened off-rails or it's a comp; the "admin generates
invoice and forwards URL" flow is a tiny niche that just
produces orphan invoices when the modal gets dismissed.
Buyers who want to pay use the SDK's /v1/upgrade.
The API path is unchanged for back-compat with scripted
operators (skip_payment defaults to true here).
2. Change-tier modal — downgrade detection + warning banner
Detects target.tier_rank < current.tier_rank (or price-diff
when ranks aren't set), renders a yellow warning card listing
the entitlements the buyer is about to lose, and confirms via
browser dialog before submit. Operator sees what they're
doing.
3. Self-tier guard on admin change-tier
POST /v1/admin/licenses/<id>/change-tier rejects when <id>
is the daemon's own self_license. Avoids the recursion Grant
hit when trying to downgrade himself: the on-disk signed key
is the source-of-truth at boot, so the DB tier_change just
produces a half-applied state. Error message points at the
right paths (re-mint via master Keysat OR rename
/data/keysat-license.txt for testing). With the P0 self-tier
live-refresh in place the recursion is now fully resolved
anyway, but the guard is good belt-and-suspenders for
operator clarity.
4. Zaprite webhook — full URL in copy + persistent action
- The Connect Zaprite action now shows the EXACT
https://your-keysat-url/v1/zaprite/webhook URL to paste
into Zaprite's dashboard. Previous copy showed a
placeholder "<your Keysat public URL>/...", which Zaprite's
form rejects (it requires full https://). Daemon's
/v1/admin/zaprite/connect now returns webhook_url; the
action displays it.
- New "Show Zaprite webhook setup" StartOS Action — operators
who skipped the step on first connect, or who lost the
output, can run this any time and get the URL again.
- Full explainer of what webhooks unlock vs polling-only:
"without webhooks, Keysat polls /v1/orders every 60s, so
license issuance lags settle by up to a minute; with
webhooks, ~1s." Lives on /v1/admin/zaprite/status response
as `webhook_explainer` + in the action's display text.
5. Connect-while-connected short-circuit
POST /v1/admin/zaprite/connect now returns 409 Conflict with a
clear "already connected — disconnect first" message instead
of silently overwriting an existing config. (BTCPay's
start_connect already had this guard since the durable
provider switch work.)
6. Lightning vs on-chain copy on the wait page
/thank-you was hard-coded to "next block confirms" — wrong
for Lightning payments (instant) and confusing in the common
case where buyers paid via Lightning and saw a "waiting for
block confirmation" message. Updated to: "Lightning settles
in seconds; on-chain typically settles in 10-20 minutes (one
block confirmation)." Method-aware copy (parsed from the
provider's invoice payload) is a deeper fix but out of scope
here — this gets the operator-facing accuracy right today.
Test count unchanged; all 77 still passing.
|
||
|
|
2fbd36fac6 |
P0 — recurring + trial + renewal-webhook + self-tier live refresh
Five fixes that were all blocking real-world use of the recurring
+ tier-upgrade features. All deeply related; bundling them into one
commit because they share data flow and would be silly to land
piecemeal.
1. Subscription row created on recurring purchase
issue_license_for_invoice now calls
subscriptions::create_subscription whenever the resolved policy
has is_recurring=1. Previously the licenses row was inserted but
no corresponding subscription, so the renewal worker never picked
it up — buying a recurring policy was silently equivalent to a
one-shot purchase. Idempotent against webhook re-delivery.
2. trial_days actually does something
/v1/purchase short-circuits BEFORE pricing/discount logic when
the chosen policy has is_recurring=1 AND trial_days > 0:
synthesizes a free invoice via repo::create_free_invoice,
issues the license inline with expires_at = now + trial_days,
creates the subscription with next_renewal_at = trial_end so the
renewal worker fires the FIRST paid invoice when the trial ends.
Buyer pays nothing today. Discount codes are deliberately
ignored on trial purchases (free + discount = no-op).
3. Trial license carries the TRIAL flag
In the regular webhook issuance path, is_trial is now set
whenever (policy.is_trial OR (is_recurring AND trial_days > 0)),
so the signed payload's TRIAL bit reflects what the buyer is
actually getting and SDK consumers can render
"trial — N days remaining" correctly.
4. Renewal-pending webhook payload enriched
subscription.renewal_pending now includes buyer_email (looked up
from the license), product_id, policy_id, cycle_start_at,
cycle_end_at, due_at, and is_first_paid_cycle. With these the
operator's webhook receiver has everything it needs to render
"your free trial is ending" vs "your monthly renewal is due"
emails and forward the checkout_url to the buyer. Without this
payload upgrade, renewal invoices were created server-side but
no one knew about them.
5. Self-tier live refresh
New license_self::refresh_self_tier_from_db re-reads the
daemon's own license row from the local DB and rebuilds
state.self_tier with LIVE entitlements (not the immutable
signed-payload entitlements). Without this, an admin Change
Tier on the daemon's own license never propagates — the
running process keeps showing whatever tier was baked in at
key-signing time, even though the DB row says otherwise.
Wired to run:
- Once at boot, immediately after check_at_boot (so any tier
change between two daemon runs takes effect on next start)
- Every hour thereafter (background task in main.rs)
- On demand via POST /v1/admin/self-license/refresh, exposed
for operators who don't want to wait for the next tick
For master Keysat (the one selling licenses) the refresh
query is local. Non-master operators in v0.3+ can extend this
to call upstream `/v1/validate`. For v0.2.x, local-DB-only
resolves your testing case (downgrade yourself, click refresh,
sidebar updates, gate tests work).
6. Buy page CTA reflects trial
When the selected tier has is_recurring=1 and trial_days > 0,
the price card renders "FREE for N days" and the button reads
"Start N-day free trial" instead of "Pay with Bitcoin". Buyer
knows they aren't being charged today.
7. Invoice model gains listed_currency + listed_value
Already in the DB schema (migration 0010); the Rust model just
wasn't reading them. Needed by #1 to set the subscription's
listed_value correctly for fiat-priced recurring policies.
Test count unchanged (77 passing). The recurring-tests-still-pass
proof point isn't the test suite (these are behavioral changes
above the renewal-worker tests' scope) — it's that the renewal
worker tests construct subscriptions explicitly and don't go
through the purchase path that was broken.
|
||
|
|
735461b3ef |
KEYSAT_INTEGRATION.md: section 11a — tier-aware purchases + in-app picker
Documents the multi-policy in-app purchase flow that the Recap dev hit a dead-end on (no obvious tier discriminator on startPurchase). Adds: - New section 11a "Tier-aware purchases — in-app tier picker (multi-tier products)" walking the full pattern: listPublicPolicies → render tier UI → startPurchase with policySlug → open checkout → poll/webhook → write key. Same shape in TS / Python / Rust / Go. - Architecture diagram showing buyer → SDK → daemon → BTCPay → key. - "When you'd use this" guidance + "Common mistakes" section including the four traps the Recap dev guessed at: hardcoding slugs, splitting products, abusing discount codes as tier selectors, omitting policySlug. - Cross-reference from question 7 in section 0 (the operator- questionnaire) so the LLM nudges toward the picker pattern when there are 2+ tiers, and back to single-tier section 11 otherwise. - Cross-reference from section 7f (frontend integration for hard-gate Flavor 2) so the activation-screen pattern surfaces the picker as an inline option. - Cross-reference from section 11 → 11a so single-policy readers who later add tiers find the upgrade path. This is the pattern Recap implements in its activation screen, and becomes the canonical example for any future multi-tier integration. SDKs (TS, Rust, Python, Go) all support it as of their 0.2.0 releases (commits c3a57a0 / 5dd301c / 94654f6 / 970f95a in their respective repos). |
||
|
|
58939d1dc6 |
v0.2.0:5 release notes — tier upgrades functional end-to-end
Bumps the milestone version + writes the operator-facing release notes covering the complete tier-upgrades feature delivered across |
||
|
|
fb062d5ca5 |
Tier upgrades Phase 5 — admin UI: tier_rank input + Change-tier modal
Closes the operator surface for tier upgrades. With this in,
operators have a complete UI for managing the upgrade ladder
without ever needing the curl-the-API path.
Policy editor (create + edit forms):
- New "Tier ladder rank (optional)" number input alongside the
recurring section. Operators set "0" for free, "1" for
standard, "2" for pro, etc. Empty input = "not in any ladder"
(server stores NULL; that policy is excluded from buyer-facing
upgrade flows but admin can still force-change to/from it).
- Edit-form behavior: empty input clears tier_rank to NULL.
Filled input sets to that value. The PATCH always sends the
field (using the nullable-patch shape Some(Option<i64>)) so
the operator's intent — clear or set — actually lands.
- Range 0–1000 enforced server-side; clipped client-side too.
Licenses page:
- New "Change tier" button on every non-revoked license row,
to the left of Suspend/Unsuspend/Revoke.
- Opens a modal that:
* Loads all policies for the license's product
* Shows them in a dropdown with metadata (rank · cadence ·
trial flags) so the operator can see the ladder shape
* Offers a "Apply as comp (skip_payment=true — no invoice,
flips immediately)" checkbox + an audit-reason field
* On submit, POSTs to the new admin endpoint:
- skip_payment=true → "Applied" status, modal closes
- skip_payment=false → renders the checkout URL the
operator forwards to the buyer through whatever channel
they use (the design-doc-spec'd "operator delivers the
URL" flow)
- The modal deliberately doesn't show a quote preview before
submit (the buyer-quote endpoint requires the buyer's signed
license key, which the admin doesn't have). Server-side
response carries the actual numbers when the operator commits.
Future polish: a separate admin-mode quote endpoint could
render the preview pre-submit.
Tests unchanged (77 still passing) — pure UI commit, no Rust
changes. The behavior the UI drives is fully covered by the
api.rs admin_change_tier_* tests added in
|
||
|
|
c5d716a6d4 |
Tier upgrades Phase 4 — admin force-change + renewal-worker hook
Closes the operator side of TIER_UPGRADES_DESIGN.md. With this in,
operators can force-change any license to any policy under the same
product (sideways, cross-NULL-rank, perpetual downgrades all
allowed) — and scheduled tier changes (e.g. recurring downgrades
recorded with future effective_at) actually fire at cycle boundaries.
New endpoint:
- POST /v1/admin/licenses/:id/change-tier
Body: { to_policy_slug, skip_payment: bool, reason?: string }
skip_payment=true (comp upgrade / support fix-up): apply
immediately, write a tier_changes row with proration=0 and
invoice_id=NULL, fire the license.tier_changed webhook, audit-log
with actor=admin_api_key.
skip_payment=false: same as buyer's /v1/upgrade — create a
provider invoice for the prorated charge, persist the local
invoice + a tier_changes row tied to it, return the checkout URL.
Operator forwards it to the buyer through whatever channel they
use. Webhook applies on settle.
Bypasses ladder rules entirely (sideways, perpetual downgrade,
recurring → perpetual all OK). Same-product / different-policy /
active-target checks still apply.
QuoteMode refactor (src/upgrades.rs):
- compute_upgrade_quote now takes QuoteMode::{Buyer, Admin}.
- Buyer mode = strict ladder rules (per Phase 2).
- Admin mode = bypass ladder + downgrade gates; infer direction
from rank-diff if both ranked, else from price-diff.
- Buyer endpoint passes Buyer; admin endpoint passes Admin.
Renewal-worker hook (src/subscriptions.rs):
- Before pricing each renewal cycle, the worker calls
apply_pending_tier_changes(state, sub). This finds tier_changes
rows for the sub's license where effective_at <= now AND
invoice_id IS NULL AND license.policy_id != to_policy_id (i.e.
scheduled comp/admin changes that haven't been applied yet).
Each pending change is applied via apply_tier_change (which
also rewrites the sub's policy_id / listed_value / period_days).
After applying, the worker re-fetches the sub and prices the
next invoice at the NEW tier's listed_value.
- This is what makes recurring downgrades actually take effect at
the cycle boundary (admin records "Pro → Standard at next
renewal", the worker applies it, the new invoice bills at
Standard's price).
- Idempotent: re-running the hook on a license already on the
target tier finds zero pending rows (the policy_id != check
filters them out).
Tests (+5, total now 77):
- admin_change_tier_skip_payment_applies_immediately — comp path
flips license + writes tier_change row with no invoice
- admin_change_tier_allows_perpetual_downgrade — the case the
buyer endpoint rejects with 400 "admin-only"
- admin_change_tier_rejects_zero_charge_paid_path — sideways
attempt with skip_payment=false hints at switching to true
- admin_change_tier_requires_admin_token — 401 without auth
- renewal_worker_applies_pending_tier_change_before_billing —
the headline behavior: a pending downgrade tier_change with
effective_at=now causes the next renewal to bill at the new
(lower) tier's price, NOT the old one. Uses a CapturingProvider
mock that stashes the last sat amount it saw so the assertion
is on what the worker actually billed.
|
||
|
|
b7fa6c7dae |
Tier upgrades Phase 3 — buyer-facing HTTP endpoints
Closes the buyer self-service tier-upgrade loop. With this in,
SDKs can wire an "Upgrade to Pro" button inside the operator's
app and the daemon handles quote → invoice → settle → apply
without operator involvement.
New endpoints (auth via signed license_key in body, same model
as /v1/recover and /v1/subscriptions/cancel — no admin token,
no cookie):
- POST /v1/upgrade-quote — read-only quote. "If I upgraded to
<tier>, what would I owe right now,
when do entitlements take effect,
what will the next renewal charge?"
- POST /v1/upgrade — buyer commits. Daemon recomputes the
quote (don't trust client shaping),
rejects 0-charge upgrades (admin path
only), creates a provider invoice for
the prorated charge in the listed
currency converted to sats, persists
the local invoice + a tier_changes
row tying them together, returns the
checkout URL.
Webhook handler change (src/api/webhook.rs):
- On invoice settle, BEFORE the subscription / license-issuance
branches, look up the invoice in tier_changes via
upgrades::get_tier_change_by_invoice. If present, run the
apply path: mutate the existing license's policy_id +
entitlements + max_machines + grace + expires_at, mutate any
tied subscription's policy_id + listed_value + period_days
(so future renewals charge the new tier), audit, fire the new
`license.tier_changed` webhook event, ack 200.
- Idempotent: re-delivered webhook on an already-applied
tier change is a no-op (license.policy_id == target.id check).
- Critically: the existing license_id is preserved. Buyers
keep the same signed key; on next online validation their
app sees the new entitlements. No new license is issued.
Phase 3 scope deliberately excludes:
- Buyer-initiated DOWNGRADES. compute_upgrade_quote already
returns 0-charge quotes for recurring downgrades (effective at
next_renewal_at), but applying that at the cycle boundary
needs renewal-worker integration. Phase 4 lands the admin
endpoint AND the worker hook in one go. For v0.2.x the buyer
endpoint rejects with 400 "admin-only".
- Admin force-change (POST /v1/admin/licenses/:id/change-tier).
Phase 4.
Tests (+6, total now 72):
- upgrade_quote_returns_perpetual_difference (Standard $25 →
Pro $75 = $50 = 5000 cents quote, "immediate" effective)
- upgrade_quote_rejects_garbage_key (401, doesn't leak whether
the target slug exists)
- upgrade_quote_rejects_unknown_target_policy (404)
- upgrade_start_creates_invoice_and_tier_change_row (verifies
the tier_changes row is written tied to the new invoice; the
license is NOT yet on Pro until settle)
- webhook_settle_on_tier_change_applies_instead_of_issuing
(full end-to-end: settle webhook fires → license flips to Pro
+ Pro entitlements appear; license count stays at 1, NO new
license issued; re-delivery idempotent)
- upgrade_endpoint_rejects_buyer_downgrade (400 "admin-only" —
the clear-message path the quote function intercepts with;
Phase 4 will introduce a separate buyer-downgrade path)
|
||
|
|
f8affdb11f |
Tier upgrades Phase 2 — quote logic + apply step
Builds on
|
||
|
|
8ce78ab9d3 |
Tier upgrades Phase 1 — schema foundation (dormant)
First step of TIER_UPGRADES_DESIGN.md (Grant + me, parent folder).
Schema-only commit; Phases 2-6 (quote logic, buyer endpoints, admin
endpoints, admin UI, buyer surface) ship in follow-ups.
Migration 0013_tier_upgrades.sql:
1. ALTER TABLE policies ADD COLUMN tier_rank INTEGER. Operator-defined
ladder ordering — higher = better tier. NULL means the policy isn't
in any ladder (existing operators see no behavior change). The
buyer-facing upgrade endpoint will validate
target.tier_rank > current.tier_rank for upgrades, and the reverse
for downgrades. Index on (product_id, tier_rank) supports the
"list this product's policies in ladder order" query.
2. New tier_changes table — one row per upgrade/downgrade. Captures:
- from_policy_id / to_policy_id with FKs into policies
- direction ('upgrade' | 'downgrade', CHECK enforced)
- listed_currency + proration_charge_value (smallest unit) for the
pricing snapshot; invoice_id nullable so comp-mode admin changes
(skip_payment=true) can write a row without an invoice
- effective_at decoupled from created_at so downgrades on recurring
subs can be RECORDED immediately but TAKE EFFECT at cycle end
- actor ('buyer' | 'admin', CHECK enforced) + free-form reason
- Three indexes covering the obvious query paths: by license
(history view), by created_at (operator analytics), partial on
invoice_id WHERE NOT NULL (webhook-handler lookup of
"is this settling invoice a tier-change?").
Migration regression test (8 tests now in tests/migrations.rs, was 7):
- Existing pre-0013 fixtures untouched, tier_rank defaults to NULL.
- tier_changes accepts a row referencing pre-0013 license/policy/invoice.
- CHECK constraints fire: bad direction, bad actor, negative
proration_charge_value all rejected.
- assert_db_clean confirms no FK / integrity drift.
Drive-by: branding design doc (parent folder) bumps its migration
number from 0013 → 0014 to avoid a collision with this one.
Test count: 58 (was 57; +1 for migration_0013_adds_tier_upgrades).
|