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.
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.
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.
Bump with notes covering the active_payment_provider preference,
the new Activate <provider> actions, and the symmetric Disconnect
handling.
Test count: 42.
Closes the gap from :2 where Connect Zaprite swapped the
in-memory provider but BTCPay would silently re-take active on
the next daemon restart (because the boot-time loader picked
BTCPay first whenever btcpay_config was present, regardless of
operator intent).
What changed:
**New settings key `active_payment_provider`** in the existing
settings table. Records the operator's last explicit choice
('btcpay' | 'zaprite' | NULL = no preference). Both
btcpay_config and zaprite_config can coexist; the flag is what
determines which one the daemon loads.
**Boot-time loader respects the preference.** main.rs now reads
the flag at startup. If set to 'zaprite', Zaprite wins; if set to
'btcpay', BTCPay wins; if unset (legacy installs), falls back to
the previous BTCPay-first ordering. Cross-load fallbacks log a
WARN and try the other provider — operators with a stale flag
pointing at a wiped config don't boot unconfigured.
**Connect endpoints write the preference.**
- finish_connect (BTCPay) now sets the flag to 'btcpay' on
successful authorize-callback completion.
- ZapriteAuthorize::connect now sets the flag to 'zaprite' on
successful API-key validation.
- Both Disconnect endpoints clear the flag IF it pointed at the
provider being disconnected — but leave it alone if it pointed
at the OTHER provider (different operator intent).
**New endpoints for fast switching without re-Connect:**
- GET /v1/admin/payment-provider/status — both configs' state +
current preference + runtime active provider, in one call.
- POST /v1/admin/payment-provider/activate { provider: "btcpay" |
"zaprite" } — flips the active provider and the flag together,
without going through the full Connect flow. 400 if the named
provider isn't configured (operator must run Connect first).
**New StartOS Actions** under existing groups:
- "Activate BTCPay" (in BTCPay group)
- "Activate Zaprite" (in Zaprite group)
Both call the new activate endpoint. Operators with both
providers configured can flip back and forth in one click.
**Test:** payment_provider_preference_round_trip pre-seeds both
configs, walks through Activate-Zaprite → Activate-BTCPay →
attempt-Activate-on-wiped-config → bad-provider-name → manual
write/read of the preference key. Pins the contract.
Test count: 42 (was 41; +1).
Migration not needed — settings table from 0005 already has the
key/value/updated_at shape we need.
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.
This release adds Zaprite as an alternative to BTCPay. Operators
can now choose between two payment rails:
- BTCPay: Bitcoin-only, you run the BTCPay Server yourself
- Zaprite: Bitcoin + fiat cards (USD/EUR via Stripe/Square), brokered
by Zaprite, settles to your connected wallets
Only one is active at a time per Keysat instance. Switching requires
Disconnect → Connect; existing license keys are unaffected. Future
v0.3 work routes per-policy choice (e.g., "free tier via Zaprite,
paid tier via BTCPay") if operators want both, but for v0.2.0:2 it's
either-or.
What's in this release:
**Migration 0011 — recurring subscriptions schema (dormant).**
Adds `subscriptions` and `subscription_invoices` tables, plus
`is_recurring`/`renewal_period_days`/`grace_period_days` (default 7)/
`trial_days` (default 0) on policies. No daemon code uses these
yet — phases 2-6 of RECURRING_SUBSCRIPTIONS_DESIGN.md land in
follow-up commits. Migration regression test covers the additive
contract against populated data.
**Migration 0012 — zaprite_config.** Singleton-row table for the
operator's Zaprite API key + base URL + recorded webhook id.
Mirrors btcpay_config from migration 0002.
**ZapriteProvider implementation.** New module at
src/payment/zaprite/ with client.rs (HTTP, Bearer auth), config.rs
(DB persistence), provider.rs (PaymentProvider trait impl). Maps
Zaprite's currency enum (BTC/USD/EUR) to/from the Money type;
maps Zaprite's order status enum (PENDING/PROCESSING/PAID/COMPLETE/
OVERPAID/UNDERPAID) to ProviderInvoiceStatus.
**Webhook security via externalUniqId round-trip.** Zaprite does
NOT publish a webhook signature scheme (verified May 2026 against
public OpenAPI + dashboard). Their docs explicitly designate
receiver-side idempotency as the security model. Keysat's defense:
attach our local invoice UUID as externalUniqId at order creation,
then trust the webhook only insofar as the order id resolves to
a local invoice in an expected state. Documented in detail in the
payment::zaprite module-level comment + the validate_webhook
docstring.
**Admin endpoints.**
- POST /v1/admin/zaprite/connect: validates the API key by pinging
GET /v1/orders before persisting; swaps active provider atomically
- POST /v1/admin/zaprite/disconnect: clears stored creds + provider
- GET /v1/admin/zaprite/status: read-only connection snapshot
- POST /v1/zaprite/webhook: webhook landing route (alias of the
existing /v1/btcpay/webhook handler since validate_webhook is
trait-level)
**StartOS Actions** under a new "Zaprite" group: Connect Zaprite,
Check Zaprite connection, Disconnect Zaprite. Operator pastes the
API key into a masked input; daemon validates + saves.
**Tests.** Two new in tests/api.rs (zaprite_webhook_event_parsing
covers the full event-type mapping + missing-id rejection +
malformed-JSON rejection; zaprite_provider_kind pins the
identification). Migration regression test for 0011. Test count
grows 39 → 41.
Operators on BTCPay see no change. Operators wanting Zaprite go
through the StartOS Actions tab → Connect Zaprite, paste their
API key, register a webhook in Zaprite's dashboard pointing at
their public Keysat URL + /v1/zaprite/webhook.
Recurring subscriptions are NOT yet operator-visible — schema only
in this release. Daemon-code that uses the subscriptions tables
(renewal worker, validate-hot-path subscription branch, admin UI)
lands in subsequent commits per the design doc's phased plan.
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.
Two new API integration tests, both targeting production-correctness
invariants worth locking down:
- free_purchase_issues_license_inline: exercises the price=0 shortcut
(price_sats_override=0 on a "free" tier policy). Verifies the daemon
synthesizes a settled invoice locally, issues a license inline, and
the inlined license_key validates round-trip via /v1/validate.
- webhook_settles_invoice_and_issues_license_idempotently: the most
important new test in this set. A pending invoice + an InvoiceSettled
webhook → license issued, status flipped. Re-delivering the SAME
webhook (which providers DO retry, sometimes aggressively) must NOT
duplicate the license. A duplicated license here means duplicated
revenue and duplicated revocation surface area — both bad. This test
pins the invariant.
MockPaymentProvider added to tests/api.rs: a test-only PaymentProvider
impl that bypasses HMAC verification and parses test-supplied JSON
bodies into ProviderWebhookEvent variants. Lets us drive deterministic
settle/expire/invalid events without a real BTCPay roundtrip. Never
compiled into the production binary.
Paid-purchase test deferred: purchase::start still uses the legacy
state.btcpay_client() compat accessor that downcasts to the concrete
BtcpayProvider, which the mock can't satisfy. Documented inline. Slots
in trivially after the trait migration on the v0.3 backlog.
Version bump to v0.1.0:42 with release notes covering everything since
:41 was published: lib.rs library refactor, the original 5 API tests
from 81066df, the 2 new ones above, KEYSAT_INTEGRATION.md restoration.
No daemon-behaviour changes for operators; straight drop-in upgrade
from :41.
Test count: 20 (9 unit + 4 migration + 7 API), up from 13 in :41.
The v0.1.0:40 migration was correct on clean installs but crashed at
COMMIT on any database with rows in discount_redemptions: SQLite's
deferred FK check saw the dropped parent's bookkeeping as unsatisfied
even after the rename. Fix is to rebuild discount_redemptions in the
same transaction (stash → drop → rebuild → restore) plus orphan
cleanup. Migration is idempotent; operators on :40 with a checksum
mismatch recover by deleting the version=9 row from _sqlx_migrations
and restarting.
Lands the missing migration test scaffolding too. The four tests in
licensing-service/tests/migrations.rs apply migrations against a
realistic populated database (products, policies, invoices, licenses,
machines, discount codes, redemptions, webhooks, tip attempts). The
regression test fails with the exact 787 error against the v40
migration — would have caught the bug pre-release.
KEYSAT_INTEGRATION.md is removed from this repo; it now lives in the
parent licensing/ folder.