Eager reservation at /v1/purchase prevents code-cap races but leaked
slots if BTCPay never fired the expiry webhook. New 5-min background
reaper scans for pending redemptions tied to expired/invalid invoices
or pending invoices older than 30 min, cancels each, and decrements
used_count so the slot returns to the pool.
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>
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>
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>
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>
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>
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>
`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>
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>
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>
- "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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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.
Bug fix:
Product entitlements catalog reads were silently dropping. Every
SELECT against the products table was missing entitlements_catalog_json
from the column list, so the PATCH handler wrote the catalog correctly
but every subsequent read returned null. Admin UI edits appeared to
vanish on save. Fix: added the column to all four product SELECTs
in repo.rs (list_products, get_product_by_slug, get_product_by_id —
one column list, replace_all). Added regression test
product_entitlements_catalog_round_trips_through_list_endpoint that
exercises the full PATCH → list round-trip the admin UI hits.
UX:
Drag-and-drop reordering on the tier-card grid. Operator drags any
tier card to a new position; on drop, parallel PATCH requests set
tier_rank 1..N based on the new visual order. Archived tiers are
excluded (their position in the ladder is moot). Edit-policy modal
retains the tier_rank number field for the two cases drag-and-drop
can't express (precise override + blank-to-remove-from-ladder).
Cursor signals grab/grabbing on hover/drag; dragging card lifts +
fades for visual feedback.
Copy:
Policies-tab section headers now show just the product name
("Keysat") instead of redundant "Keysat — keysat". Entitlements-
catalog row editor description placeholder shortened from
"Description (shown on buy page tooltip)" to "Description (buyer
tooltip)" so it fits the column; full hover hint kept on the
input's title attribute.
Test count: 87.
Adds tower-http CorsLayer at the outermost router position so:
- Browsers can fetch /v1/products/<slug>/policies, /v1/openapi.json,
/v1/issuer/public-key, /v1/validate from any origin. Unblocks the
dynamic pricing page on docs.keysat.xyz reading live tier config
from licensing.keysat.xyz.
- Preflight OPTIONS is handled by the CorsLayer directly, never
reaches the session-bridge or any handler — so admin endpoints
don't 401 on preflight.
Security posture unchanged. Access-Control-Allow-Credentials is OFF.
The combination of ACAO=* and no-credentials means a cross-origin
page can read public responses but can't ride a logged-in admin
session cookie to hit /v1/admin/*. Admin endpoints still require
an explicit Bearer token, which browsers don't auto-attach
cross-origin.
Tests: +2 CORS regression tests (cors_allows_cross_origin_on_public_
endpoints, cors_preflight_returns_2xx_without_auth). Full suite:
85 passing.
Both tabs now group by product (matching the per-product card
sections in Products + Policies), with product-filter pills + per-
product counts at the top. Multi-product instances see one section
per product with a status breakdown subtitle ("3 active · 1
revoked · 2 expired"); single-product instances continue to see a
flat table with no chrome overhead. Search results bypass grouping
(search is global across all products).
Three new shared helpers added at the top of the script:
- clickToCopy(fullValue, displayLabel) — clickable code element
that copies the full ID to clipboard with a "✓ copied"
indicator. Replaces the older hover-to-see-full-id UX for
license / subscription IDs.
- relativeDate(rfc3339, opts) — renders an RFC3339 timestamp as
a human-relative string ("in 3 days" / "12 hours ago") with
the absolute timestamp in a hover tooltip. Applied to license
issued/expires + subscription next_renewal.
- reasonModal({title, message, warning, confirmLabel,
confirmVariant}) — inline overlay-card replacement for the
native prompt() / confirm() dialogs. Used by:
* Subscription cancellation flow
* License suspend / unsuspend / revoke flows
Same UX language as the Change Tier modal.
Subscriptions tab specifics:
- Product filter pills with per-product counts (filtered by
active status filter so the counts reflect what the operator
is currently viewing).
- Status filter pills gain counts (Active (3), Past due (0), etc.)
- New Product column shows display name + slug.
- Status badges have hover tooltips explaining each state's meaning.
- Cancel button uses reasonModal instead of prompt().
Licenses tab specifics:
- Quick-stats row: Licenses / Active / Revoked / Expiring < 30d.
Scope follows the active product filter; hover "?" icons
define each stat. Mirrors the Overview dashboard style.
- Search affordance preserved; search results render as a single
flat table titled "Search results" (not grouped by product).
- Manual-issue form's hint blocks replaced with help icons on
every field. Compact-form treatment to match Products + Policies.
- Suspend / unsuspend / revoke buttons use reasonModal with
per-action context (irreversible warning on revoke, etc.)
instead of confirm() + prompt() double-dialog.
- Entitlements rendered with display name primary + description
tooltip (resolves against the product's catalog from
/v1/products's response).
Pure UI release. 78/78 tests still pass. No schema, SDK, or
behavior change.
The Policies tab gets the redesign Grant asked for: replace the
table view + verbose disclosure form with a card grid where each
existing policy renders as a buy-page-style tier card sitting next
to a dashed "+ Add tier" placeholder. Click the placeholder, it
morphs into an editable draft tier card with inline form fields;
submit Create on the card and it flips into a read-only preview.
Multiple drafts can coexist for parallel multi-tier authoring with
side-by-side comparison.
New JS helpers:
- helpIcon(text) — small "?" hover tooltip for compact form labels
- slugify(s) — URL-safe slug derivation from display name
- renderTierCard(pol, product, onMutate) — read-only buy-page-style
preview card with Edit / Hide-Show / Delete actions
- renderAddTierCard(onClick) — dashed placeholder with "+" affordance
- renderDraftTierCard(product, onCommit, onCancel) — inline editable
card with name + slug + price + duration + entitlement bubble
picker + recurring/trial toggles
- renderPolicyCardGrid(product, policies, byPolicyCounts, onMutate) —
ties them together. Submitting "+ Add tier" appends a fresh
placeholder, so operators can keep clicking to author multiple
tiers in one session.
formInput() upgraded:
- New `help:` option renders a helpIcon next to the label (replaces
verbose hint text under the input)
- New `placeholder:` option for cleaner empty-state cues
Auto-slug:
- Product create form's Display name field mirrors a slugified
version into the Slug field as the operator types — until they
manually edit the slug, which arms a "userOverridden" guard so
manual edits stick. Re-arms when the slug field is cleared.
Legacy "Create a new policy" disclosure form unsurfaced from
the Policies route — the card grid replaces it. Advanced fields
(custom grace seconds, tip recipient, tier rank) still live on the
existing Edit modal of an already-committed tier card. Power-user
flow: card grid creates the basics, Edit modal refines.
Test count unchanged (78). UI-only release.
Notes cover the entitlements catalog feature shipped in 68dfe7f
plus the four SDK 0.3.0 cuts (TS / Rust / Python / Go) that
surface the catalog on listPublicPolicies. Phase 2 (side-by-side
card-grid policy authoring UI) is queued for v0.2.0:9.
KEYSAT_INTEGRATION.md section 8 grows a subsection explaining the
catalog mechanics: bubble picker, buy page rendering, SDK surface,
catalog-stability rule.
Test count: 78 (unchanged from :7 except for migration_0014 already
counted in the prior commit).
The Start9 registry card was still showing "Keysat — self-hosted
Bitcoin-paid software license server" while keysat.xyz now leads
with "Bitcoin-native self-hosted licensing service for software
creators." Operators landing on the registry from the marketing
site got a jarring tagline mismatch.
Aligned everywhere the old copy was hardcoded:
- startos/manifest/i18n.ts (short + long descriptions — these
drive the registry card)
- assets/ABOUT.md (in-StartOS About panel)
- README.md (root + licensing-service/)
- licensing-service/Cargo.toml description
Long description also picked up two updates that should have
landed when the features did but never made it into the marketing
copy:
- Zaprite mention (Bitcoin + cards) alongside BTCPay
- Recurring subscriptions + in-place tier upgrades
Pure copy change. No code, no behavior, no schema. Republishing as
:7 because the registry card text lives inside the .s9pk and
won't refresh on operators' boxes without a version bump.
Bump with notes covering the active_payment_provider preference,
the new Activate <provider> actions, and the symmetric Disconnect
handling.
Test count: 42.
Bump to v0.2.0:2 with notes covering Zaprite as second payment
provider, migration 0011 (recurring subs schema dormant), 0012
(zaprite_config). Test count 41.
Per operator feedback: the discount-code field on /buy/<slug> was
showing 'FOUNDERS50' as a placeholder, which confused buyers (some
tried it as a real code, some assumed Keysat shipped a default
discount). Empty placeholder now; buyers paste their actual code.
No semantic change. Wrapper-only revision; daemon binary unchanged
beyond the embedded HTML template.
Swaps the version graph's current pointer from v0_1_0 to v0_2_0.
v0.1.0 stays in `other` so operators on the alpha line can upgrade
through the StartOS marketplace.
Per CUTTING_V0.2.0.md the steps are:
1. swap versions/index.ts (this commit)
2. npm run check (passed)
3. make x86 (next)
4. publish.sh (next)
What v0.2.0:0 represents — see the release notes in
startos/versions/v0.2.0.ts. Headlines: web admin SPA replaces
Actions for day-to-day work; multi-currency pricing functional
end-to-end; buyer self-service recovery; opt-in community
analytics; webhook delivery DLQ visible in dashboard;
PaymentProvider trait abstraction makes Zaprite drop-in for v0.3;
five-language SDK parity (daemon + Rust + TS + Python + Go).
Bump with notes covering the SPA polish batch + edit-product currency
support. Last polish pass before v0.2.0:0 cutover.
Test count unchanged at 38. Straight drop-in upgrade from :50.
Bump version with release notes covering Phases 2-6 of the multi-
currency design (admin UI write path, buy page fiat rendering, rate
fetcher, invoice rate recording, currency-aware discount codes).
Operators can list products in USD/EUR and accept BTC; the daemon
converts at invoice creation and pins the rate.
Test count: 37. Straight drop-in upgrade from :48.
Bump version with release notes for migration 0010 (additive multi-
currency columns + backfill) and the model/repo updates wiring
the new fields into the read/write paths.
Test count: 33. Straight drop-in upgrade — no admin action,
backfill runs automatically in the migration transaction.
Bumps version with release notes covering:
- Community analytics opt-in (admin Overview surface, off by default,
full privacy disclosure including a live preview of the exact
JSON heartbeat that would be sent)
- Floor-to-5 anti-fingerprinting on counts pinned by test
- Draft v0.2.0:0 release notes parked at startos/versions/v0.2.0.ts
- CUTTING_V0.2.0.md cutover guide
Test count: 32. Straight drop-in upgrade from :46.
Adds startos/versions/v0.2.0.ts as a draft milestone version entry,
ready to swap in as `current` when we're ready to cut. NOT yet wired
into the version graph at versions/index.ts — flipping that switch
is a release decision (one-line change there, then make x86 +
publish), and the draft sits parked so we can iterate on the
release-notes content without committing to the cut.
Format note: the SDK's VersionInfo.of() expects releaseNotes as a
LocaleString (Record<string, string>), not the string[] form
v0.1.0.ts uses. The new file uses the modern shape; v0.1.0.ts keeps
its existing form to avoid churn on the alpha line.
CUTTING_V0.2.0.md walks the operator (or future me) through the
4-step cutover: edit versions/index.ts to swap in v0_2_0, npm run
check, make x86, publish. Plus rollback notes if anything goes
sideways post-cut.
Why park rather than cut now:
1. The user said "prepare for the version 0.2 plumbing" — that's
"prepare" not "do". The cutover is intentional in the user's
workflow, not bundled into a routine push.
2. Cutover changes how the StartOS marketplace renders the upgrade
dialog to existing :N installs; best to QA the release-notes
content first.
3. SDK migration-API behavior on the upstream version bump is
worth verifying on a test install before flipping for everyone.
The v0.2.0 release notes themselves are written conservatively —
they describe what's already shipped and stable in the alpha line
through :47, not aspirational v0.3 features.
Closes the last T1 BTCPay UX gap from V0.2_PLAN. Connect now checks
/v1/admin/btcpay/status first; if a connection exists, returns a
clear "already connected" guidance message pointing the operator at
Disconnect → Connect for re-authorize cases. Without this guard,
re-clicking Connect spawned a new webhook subscription on BTCPay's
side every time, leaving orphan webhooks BTCPay would keep trying
to deliver to.
The Go SDK has been written and verified — all 4 crosscheck tests
pass against the shared tests/crosscheck/vector.json (the same file
the Rust/TS/Python SDKs and the daemon test against). Pure stdlib,
zero third-party dependencies. Hosted in its own repo at
github.com/keysat-xyz/keysat-client-go (private during alpha).
This release IS the 5th-language milestone: daemon + Rust + TS +
Python + Go all agree byte-for-byte on the LIC1 wire format.
Daemon binary unchanged — wrapper-only revision.
Bump version with release notes covering the two operator-facing
additions in f6ba1c1:
- POST /v1/recover (+ GET /recover HTML form) for buyer self-service
- GET /v1/admin/db-info for db health snapshot
Test count: 31 (was 30). Straight drop-in upgrade from :44.
Bumps version with release notes covering everything since :43:
- Webhook DLQ visible in admin SPA with one-click retry
- reconcile.rs + tipping.rs migrated onto PaymentProvider trait
(production refactor; daemon's non-test code now contains zero
calls to the BTCPay-specific compat accessors)
- 3 worker integration tests pin the retry/dead-letter behavior
empirically against real HTTP receivers
- 4 daemon-side crosscheck tests pin the wire-format parser
against the same vector.json the SDKs use independently
Test count: 30 (was 23). Straight drop-in upgrade from :43.