- "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.
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.