v0.1.0:25–40 — tier model, edit forms, force-delete, license counts, migration 0009 (and hotfix); KEYSAT_INTEGRATION.md merged with downstream-LLM revisions

This commit is contained in:
Grant
2026-05-07 23:35:22 -05:00
parent 6ac118ae70
commit beedd07f07
27 changed files with 5576 additions and 134 deletions
+2
View File
@@ -22,11 +22,13 @@ import { sdk } from '../sdk'
import { activateLicense, showLicenseStatus } from './activateLicense'
import { btcpayStatus, configureBtcpay, disconnectBtcpay } from './configureBtcpay'
import { setOperatorName } from './setOperatorName'
import { setWebUiPassword } from './setWebUiPassword'
import { showCredentials } from './showCredentials'
export const actions = sdk.Actions.of()
// General
.addAction(setOperatorName)
.addAction(setWebUiPassword)
// BTCPay setup
.addAction(configureBtcpay)
.addAction(btcpayStatus)
+93
View File
@@ -0,0 +1,93 @@
// Action: set or rotate the web UI password.
//
// Until v0.1.0:28 the only way to sign into the admin web UI was to paste
// the admin API key into a localStorage-backed login form. This action
// lets the operator set a real password instead — argon2id-hashed and
// stored in the daemon's settings table. After it's set, the SPA login
// page shows a password field; existing API key continues to work for
// automation.
//
// Rotating the password invalidates all existing sessions (forced
// re-login). Minimum length: 12 characters, enforced server-side.
import { sdk } from '../sdk'
import { store } from '../fileModels/store'
import { adminCall, LICENSING_URL } from '../utils'
const { InputSpec, Value } = sdk
const input = InputSpec.of({
password: Value.text({
name: 'New password',
description:
'Minimum 12 characters. This is the password you will use to ' +
'sign into the admin web UI at /admin/. Setting (or rotating) ' +
'this invalidates any active web sessions — you will need to ' +
'sign in again with the new password.',
required: true,
masked: true,
minLength: 12,
default: null,
placeholder: '••••••••••••',
}),
confirm: Value.text({
name: 'Confirm password',
description: 'Re-type the password exactly to catch typos.',
required: true,
masked: true,
default: null,
placeholder: '••••••••••••',
}),
})
export const setWebUiPassword = sdk.Action.withInput(
'set-web-ui-password',
async () => ({
name: 'Set web UI password',
description:
'Set or change the password used to sign into the admin web UI. ' +
'Replaces the API-key paste step on the login page.',
warning:
'Rotating the password signs out every active web session and ' +
'forces a fresh login.',
allowedStatuses: 'only-running',
group: 'General',
visibility: 'enabled',
}),
input,
// No prefill — passwords are sensitive.
async () => null,
async ({ effects: _effects, input: formInput }) => {
const storeData = await store.read().once()
if (!storeData) throw new Error('Store not initialized — restart the service.')
if (formInput.password !== formInput.confirm) {
throw new Error('Passwords do not match. Re-type carefully.')
}
if (formInput.password.length < 12) {
throw new Error('Password must be at least 12 characters.')
}
const resp = await adminCall(
LICENSING_URL,
storeData.admin_api_key,
'/v1/admin/web-password',
{ method: 'POST', body: JSON.stringify({ password: formInput.password }) },
)
if (!resp.ok) {
throw new Error(
`Password update failed: HTTP ${resp.status}${await resp.text()}`,
)
}
return {
version: '1',
title: 'Web UI password set',
message:
'Password saved. Next time you visit the admin web UI, sign in ' +
'with the new password. Any existing browser session was invalidated; ' +
'all signed-in tabs need to log in again. The admin API key continues ' +
'to work for automation.',
result: null,
}
},
)
+1 -1
View File
@@ -13,7 +13,7 @@ import { short, long } from './i18n'
export const manifest = setupManifest({
id: 'keysat',
title: 'Keysat',
title: 'Keysat Licensing',
license: 'LicenseRef-Proprietary',
packageRepo: 'https://github.com/keysat-xyz/keysat-startos',
upstreamRepo: 'https://github.com/keysat-xyz/keysat',
+206 -1
View File
@@ -9,8 +9,213 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v0_1_0 = VersionInfo.of({
version: '0.1.0:24',
version: '0.1.0:40',
releaseNotes: [
`Alpha-iteration revision 40 of v0.1.0 — Hotfix: migration 0009 in v0.1.0:39 was malformed and put the daemon in a startup-restart loop. This release is the corrected migration; install it to recover.`,
``,
`**The bug.** Migration 0009 in :39 did its own \`BEGIN TRANSACTION\` / \`COMMIT\` and a \`PRAGMA foreign_keys = OFF\`. sqlx-migrate already wraps each .sql file in a transaction, and SQLite doesn't allow nested transactions — so the inner BEGIN failed, sqlx rolled back the wrapping txn, the migration was never recorded as applied, and the daemon panicked on every boot. StartOS showed "Running" but the API was unreachable because the process kept exiting before binding port 8080.`,
``,
`**The fix.** Removed the redundant BEGIN/COMMIT (sqlx provides them). Replaced \`PRAGMA foreign_keys = OFF\` with \`PRAGMA defer_foreign_keys = 1\`, which is transaction-local — it postpones FK checks until COMMIT time without trying to disable them globally inside a transaction. Since the rebuild preserves all row IDs, the FK check at COMMIT passes cleanly.`,
``,
`**No data loss in :39's failure.** sqlx rolled back the wrapping transaction, so the discount_codes table is unchanged from :38. The new migration 0009 will apply cleanly to your existing database when you install :40.`,
``,
`Alpha-iteration revision 39 of v0.1.0 — Edit-able products and policies, license counts in the admin tables, and a critical migration fix.`,
``,
`**Migration 0009 — fix the discount-codes CHECK constraint to allow 'set_price'.** The 'set_price' kind was added at the daemon layer in v0.1.0:26, but the original 0004 migration's CHECK constraint only listed 'percent' / 'fixed_sats' / 'free_license'. SQLite was rejecting any insert with 'set_price' as "CHECK constraint failed". Migration 0009 rebuilds the discount_codes table with the four-kind CHECK; SQLite doesn't support ALTER TABLE DROP CONSTRAINT so the rebuild is the right path. All existing rows are preserved.`,
``,
`**Edit products from the admin UI.** New endpoint PATCH /v1/admin/products/:id and a corresponding Edit modal on the Products tab. Mutable: name, description, price_sats. Slug is intentionally not editable (it's part of the public buy URL — changing it breaks bookmarks; operators should disable + create a new product to rename).`,
``,
`**Edit policies from the admin UI.** New endpoint PATCH /v1/admin/policies/:id and a corresponding Edit modal on the Policies tab. Mutable: name, tier description, price override, duration (preset or custom), grace period, max devices, trial flag, entitlements, "Most popular" highlight. Slug + product + tip config are not editable here (tip has its own dedicated PATCH with separate validation rules).`,
``,
`**License counts in the Products and Policies tables.** New endpoint GET /v1/admin/licenses/counts returns license counts grouped by product_id and policy_id. The Products tab gains a Licenses column; the Policies tab gains the same. One COUNT-by-group query per fetch; cheap.`,
``,
`**On the discount-code-per-product question:** the existing create-code form already has a "Restrict to product slug (optional)" field — set it to scope the code to one product. Codes left unrestricted (slug field blank) work for any product. No change needed.`,
``,
`Alpha-iteration revision 38 of v0.1.0 — Force-delete for products and policies, exposed in the admin UI. Lets operators wipe test data they've accumulated against a product (or policy) — including issued licenses and invoices — without dropping to the SQLite shell.`,
``,
`**Backend.** \`DELETE /v1/admin/products/:id\` and \`DELETE /v1/admin/policies/:id\` now accept an optional \`?force=true\` query param.`,
` - Without \`force\` (the safe default): refuses with 409 if any invoice or license references the product / policy. Same behavior as :33.`,
` - With \`?force=true\`: cascades through the dependency tree in a single transaction — machines → discount redemptions → licenses → invoices → policies / codes → product. Audit log records the cascade counts (cascaded_licenses, cascaded_invoices, etc.) for forensic backtracking. Audit action is "product.force_delete" / "policy.force_delete" so the destructive variant is searchable.`,
``,
`**Admin UI.** Both Delete buttons now flow through a smart \`safeOrForceDelete\` helper:`,
` - First click → tries the safe DELETE. If it succeeds, done.`,
` - If the server returns 409 → opens a force-delete modal showing the original conflict message + a "type the slug to confirm" GitHub-style input. The "Force delete (irreversible)" button stays disabled until the typed slug matches exactly.`,
` - On confirm → POSTs with \`?force=true\`, then shows a brief toast summarizing what got cascaded ("product 'foo' force-deleted — also wiped: 3 license(s), 5 invoice(s)").`,
``,
`Type-the-slug confirmation prevents accidental nukes — single-click can never wipe customer history. Designed for the operator-tinkering use case where you've issued test licenses against a product you want to delete cleanly.`,
``,
`**Why expose this in the UI.** The same effect was previously achievable only via direct SQL inside the container (sqlite3 /data/keysat.db). Operators tinkering with new products + policies hit this constantly during pre-launch testing, and dropping into the container for what's logically a UI operation is hostile. This puts the same capability behind a sensible-friction confirmation dialog, with proper audit logging that the SQL path skipped.`,
``,
`Alpha-iteration revision 37 of v0.1.0 — Critical hotfix: the tier picker on /buy/<slug> was completely broken on every page load (TDZ error in the on-load \`selectTier(selectedPolicy)\` sync I added in :32). Symptom: clicking a tier card didn't update the price card; clicking Pay with Bitcoin caused a page reload instead of submitting; submit handler never attached because the IIFE threw before it ran.`,
``,
`Root cause: \`let appliedCode\` was declared after the on-load selectTier call, but selectTier's body reads \`appliedCode\` (\`if (appliedCode) { ... }\`). The on-load call hit appliedCode in its temporal-dead-zone, ReferenceError thrown, IIFE aborted, every subsequent handler attachment skipped.`,
``,
`Fix: hoisted \`let appliedCode = null;\` to the top of the IIFE alongside the other state variables. One line move; no behavior change beyond "the tier picker actually works again."`,
``,
`Why this slipped through: the on-load selectTier call shipped in :32 alongside a bunch of other changes; the bug only manifests when a tier is server-pre-selected, which is the common case but wasn't the path I exercised in the post-:32 sanity walkthrough.`,
``,
`Alpha-iteration revision 36 of v0.1.0 — Three test-driven UX fixes from the v0.1.0:35 dogfood walk-through.`,
``,
`**Tier-cap 402 is now an actionable modal**, not a text alert with the URL pasted into the message string. Server-side: AppError::PaymentRequired now carries \`{message, upgrade_url}\` separately and emits a structured 402 body. Client-side: api() throws errors annotated with \`status\` and \`body\`; a new handleTierCap() helper renders a cream/gold modal with a real "Get Pro license →" button when the response is a 402 with an upgrade_url. Falls back gracefully to the existing inline status pill / alert for non-tier-cap errors.`,
``,
`**Product delete is less restrictive.** Pre-:36 it refused if any policy referenced the product. That blocked the legitimate "I made setup mistakes and want to clean up" flow. Now: refuses only if INVOICES or LICENSES exist (real customer history). Policies and product-scoped discount codes get cascade-deleted in a single transaction since they're templates with no audit-trail value on their own. Audit log records the cascade counts for traceability. The policy-delete safety pattern is unchanged (still refuses on invoices+licenses).`,
``,
`**Manual license issuance from the admin UI.** The Licenses tab gains a "Manually issue a license" disclosure with a clean form: Product + Policy dropdowns (policies auto-filter when product changes), optional buyer email, optional internal note. Submit POSTs to the existing /v1/admin/licenses endpoint and shows the resulting signed key in a "Save it now" modal with a Copy button. Useful for self-issuing a Pro license to dogfood, comp licenses for press / partners / friends, or paper-licensing flows that don't go through BTCPay.`,
``,
`Alpha-iteration revision 35 of v0.1.0 — Fix the "Embed your public key" tip card actually showing the key. The :32 endpoint returns \`public_key_pem\` but the SPA was reading a stale \`public_key_b64\` field name from the very first scaffold of the admin UI. Renamed reading-side to accept either shape. Preview now strips the PEM BEGIN/END headers so the 12+12-char preview shows the actual key bytes instead of "-----BEGIN PUB…BLIC KEY-----". Copy button still copies the full PEM verbatim, ready to paste into source.`,
``,
`Alpha-iteration revision 34 of v0.1.0 — Display name on the StartOS services list reads "Keysat Licensing" instead of just "Keysat". Distinguishes the package from anything else in the future Keysat product family. Package id remains \`keysat\` (no migration); only the human-facing title changed.`,
``,
`Also bundled in this build (carried over from :33's Dockerfile change which was applied after some folks had already built :33): \`sqlite3\` is now in the runtime container, so \`start-cli package attach keysat\` operators have an SQL shell on hand for occasional admin tasks.`,
``,
`Alpha-iteration revision 33 of v0.1.0 — Keysat dogfoods its own tier model. Creator / Pro / Patron are now real, with caps enforced server-side and a persistent upgrade banner in the admin sidebar.`,
``,
`**Tier model.** Three tiers, all derivable from entitlements on the daemon's self-license:`,
` - **Creator** — entitlements: ["self_host"]. Caps: 5 products, 5 policies/product, 5 active discount codes. BTCPay only, one-time purchases. Sold for ~21,000 sats; also distributable via free codes for hobbyists.`,
` - **Pro** — entitlements: ["self_host", "unlimited_products", "unlimited_policies", "unlimited_codes", "recurring_billing", "card_payments", "team_seats"]. Unlimited everything, unlocks Zaprite + recurring billing when those ship in v0.3.`,
` - **Patron** — Pro + ["patron"]. Same feature surface, plus a Patron badge. Voluntary upsell for funding development.`,
` - **Unlicensed** — same caps as Creator. Operators can install and use Keysat without paying us anything; the upgrade pull is organic when they need more capacity.`,
``,
`**Server-side caps enforced** in /v1/admin/products, /v1/admin/policies, /v1/admin/discount-codes create handlers. Returning HTTP 402 Payment Required with a clear message and an \`upgrade_url\` pointing at the master Keysat's buy page. Caps fire at create-time only — operators above the cap aren't retroactively kicked off.`,
``,
`**Persistent upgrade banner** in the admin sidebar. Always visible (regardless of whether you're at a cap). Shows current tier label, contextual message, and the next-tier CTA. Patron operators see "Thank you for funding development" instead of an upgrade pitch. Pro operators see the Patron CTA. Creator/Unlicensed see the Pro CTA with a usage line ("Currently using 3/5 products"). Backed by a new admin endpoint GET /v1/admin/tier returning {tier, entitlements, usage, caps, upgrade_url}.`,
``,
`**Delete buttons on Products and Policies.** Same safety pattern as the existing discount-code Delete: hard-delete refused with 409 if any references exist (policies/invoices/licenses for a product; invoices/licenses for a policy). Operator should disable / hide instead in that case. Audit-logged.`,
``,
`**Pricing page** at keysat-docs/pricing.html — Creator / Pro / Patron tier comparison cards, what the caps count, how to switch tiers, what's coming in v0.3.`,
``,
`**Test-data wipe doc** at RESET_TEST_DATA.md (root of the workspace) — one-liner SQL for clearing pre-launch test data on a master Keysat that's accumulated stale products/policies/licenses during testing.`,
``,
`**No DB schema changes** — caps are enforced via existing entitlements field on the License/Policy models. Migrations 00010008 unchanged.`,
``,
`Alpha-iteration revision 32 of v0.1.0 — Three real bugs the first end-to-end tier test surfaced.`,
``,
`**Free-tier checkout no longer goes through BTCPay.** Before: clicking Pay on the Free tier created a 0-sat (well, 1-sat — BTCPay floor) invoice and dropped the buyer on a confusing "amount paid: 0 BTC" receipt. They had to click "Return to Keysat" to actually get the license. Now: when /v1/purchase computes a final price of 0 (free tier price_override=0, OR a paid tier with 100%-off), the daemon synthesizes a settled invoice locally, issues the license inline, and returns the signed key in the response body. The buy page detects the inline-license response and renders the license card directly — no BTCPay roundtrip, no fake receipt, no extra clicks. The button label also updates correctly: clicking a Free tier on the picker flips the CTA to "Redeem license" and the price card to "FREE", so the buyer sees the right state before submitting.`,
``,
`**Public key now resolves at the documented endpoint.** The admin Overview's "Embed your public key" tip was showing "unavailable" because the SPA fetches /v1/issuer/public-key but no handler was wired. Added the GET endpoint (no auth required — public keys are by definition public). The same endpoint will be useful to SDK consumers fetching the operator's signing key dynamically. Returns \`{public_key_pem, key_algorithm, key_format_version}\`.`,
``,
`**Admin Licenses table now shows entitlements + policy.** Two new columns:`,
` - **Policy**: the policy slug under which the license was issued (e.g. "free", "pro", "patron"). Hover for the policy's display name.`,
` - **Entitlements**: small mono-style chips for each entitlement on the license (e.g. \`self_host\`, \`recurring_billing\`).`,
`The product column now shows the product slug instead of a UUID prefix. Backed by a server-side enrichment in /v1/admin/licenses/search that joins to policies + products in two small queries — same response shape, just with extra fields.`,
``,
`**On the BTCPay "Return to Keysat" button label** (you asked): yes, it's customizable, but from BTCPay's side, not Keysat's. In your BTCPay → Store Settings → Checkout Appearance, there's a "Custom checkout CSS / store branding" area where you can tweak the button text and colors. The label is rendered by BTCPay's invoice frontend, not by Keysat. Recommended: change "Return to Keysat" to "View license" or "Get license" — same redirect, friendlier copy.`,
``,
`Alpha-iteration revision 31 of v0.1.0 — Tip-suggestion copy uses keysat@primal.net directly instead of the brand-aliased tip@keysat.xyz. Avoids the LNURL-pay static-proxy setup until we want a branded address.`,
``,
`One-line text change in the policy create form's tip-recipient hint and example. No behavior change otherwise — operators can paste any Lightning Address.`,
``,
`Alpha-iteration revision 30 of v0.1.0 — Price-per-tier on the policy form. Operators can set Free=0, Pro=250000, Patron=500000 etc. directly without curl gymnastics.`,
``,
`New "Price (sats)" field on the policy create form. Pre-fills with the chosen product's base price (the dropdown shows each product's price inline so the operator doesn't have to remember). Picking a different product re-prefills, unless the operator has already edited the value away from the prefill — in which case their edit is preserved (no clobbering).`,
``,
`Wire path: form → JSON body's \`price_sats_override\` → existing /v1/admin/policies POST → existing \`price_sats_override\` column on policies. The buy-page tier picker reads this for its per-card pricing. \`price_sats_override = 0\` works as a free tier (the buyer is never charged).`,
``,
`No backend changes — the API has accepted \`price_sats_override\` from day one; we just weren't exposing it on the form.`,
``,
`Alpha-iteration revision 29 of v0.1.0 — policy-create form rebuilt to remove the JSON-foot-guns. No more "do I type the brackets?" moments.`,
``,
`**Form-level changes** to the Policies → Create-a-new-policy disclosure in the admin SPA:`,
` - **Duration** is now a preset dropdown (Perpetual / 7d / 30d / 90d / 6mo / 1y / 2y / Custom) instead of a raw seconds input. Custom drops back to a seconds field for power users; otherwise the operator never sees the number 31536000 again.`,
` - **Grace period** moved to days (was seconds). The form does the *86400 conversion silently.`,
` - **Tier description** is now a dedicated text field. It writes to \`metadata.description\` under the hood, which the buy-page tier picker reads. Operators never see the metadata JSON.`,
` - **Mark as "Most popular"** is now a checkbox. Writes to \`metadata.highlight\` so the corresponding tier card gets a gold "Most popular" pill on /buy/<product>.`,
` - **Entitlements** is now a textarea (was a single-line input). Operators can write one entitlement per line OR comma-separated. The form is also defensive: it strips any \`[\`, \`]\`, \`"\`, \`'\` characters in case someone pastes a JSON-style array. No more "did I need quotes?".`,
` - **Max machines** relabeled "Max devices (0 = unlimited)" — clearer than "machines".`,
` - Inline help text on every field.`,
``,
`OpenSats Lightning address corrected to \`opensats@npub.cash\` (was the older nostrplebs.com address) in the tip-recipient suggestions.`,
``,
`**No backend changes.** The policies API has always accepted a \`metadata\` JSON object; we just weren't exposing the right fields in the form. Existing policies continue to work; their metadata is preserved on read/write.`,
``,
`Why this matters: the buy-page tier picker (shipped in :27) needs descriptions and highlighting to look right. Until :29 the only way to set those was to know the metadata schema and craft a JSON object. Now there's a checkbox and a text field. Closer to the bar where a non-developer operator can ship a paid product without reading source.`,
``,
`Alpha-iteration revision 28 of v0.1.0 — password-based admin auth, BTCPay revenue stats on the Overview, and a positioning update on the Keysat website.`,
``,
`**Password-based admin web UI (replaces API-key paste).** New StartOS action **General → Set web UI password** lets the operator define an argon2id-hashed password (12-char minimum). After setting it, the admin login page shows a password field instead of asking for the API key. Sign-in mints a 24-hour HttpOnly+Secure+SameSite=Strict session cookie; sliding renewal on every authenticated request. The API key continues to work for automation and is still the fallback for the very first login (before a password has been set). Brute-force protection: per-IP token bucket on /admin/login (5-attempt burst + 1 token per 3 minutes). Rotating the password invalidates all active sessions.`,
``,
`Implementation: cookie sessions ride on top of the existing API-key require_admin guard via a small axum middleware (\`session_to_bearer\`) that injects the API key as Authorization when a valid cookie is present. Every existing admin handler keeps working unchanged. Audit-log limitation: cookie-authenticated calls show the API key's sha256 as the actor. IP / user-agent on the same row distinguish sessions in practice; per-session actor identity is a v0.2 follow-up.`,
``,
`**Migrations:** \`0008_web_sessions.sql\` adds a \`sessions\` table (additive). \`web_ui_password_hash\` lives in the existing settings table.`,
``,
`**New crate:** \`argon2 = "0.5"\` (PHC-recommended pure-Rust hashing). Adds ~30 KB to the binary.`,
``,
`**New endpoints:**`,
` - POST /admin/login (public; password → cookie)`,
` - POST /admin/logout (clears cookie + deletes session row)`,
` - GET /admin/login/status (public; \`{has_password, logged_in}\`)`,
` - POST /v1/admin/web-password (admin-only; sets/rotates the password hash)`,
``,
`**Background task:** hourly session reaper drops expired rows.`,
``,
`**BTCPay revenue stats on the Overview.** New stat card "Revenue (lifetime)" plus a four-cell breakdown (lifetime / 30d / 7d / 24h) below the existing stats row. Backed by a new endpoint \`GET /v1/admin/revenue/summary\` that sums \`amount_sats\` across settled invoices in the local DB. Free-license redemptions have amount=0 and don't contribute. We deliberately don't call the BTCPay API for this — every invoice we created is in our DB with status + amount, so a local SUM is faster and works even when BTCPay is down. (If we ever want refunds / fees / Lightning-vs-onchain breakdown, that's when we'd add a BTCPay roundtrip.)`,
``,
`**Landing-page positioning update** (separate repo, keysat-xyz-landing). New paragraph in the hero: "Keysat empowers independent software creators to monetize any software they choose to sell — fully open source, free/paid versions, or fully closed source. The licensing layer is agnostic to your decision." Distinguishes Keysat from open-source-only / SaaS-required licensing services and makes explicit that the operator owns the model decision.`,
``,
`Alpha-iteration revision 27 of v0.1.0 — tiered pricing on the buy page. Operators with multiple policies now see a side-by-side tier picker on /buy/<slug>; buyers pick a tier explicitly; the chosen policy round-trips through purchase → BTCPay invoice → settlement webhook → license issuance.`,
``,
`**The picker.** When a product has 2+ active+public policies, /buy/<slug> renders a card grid above the existing form: each card shows tier name, price, duration, key entitlements (as bullets), and a Select button. Selecting a tier highlights its card with a gold border + ring shadow, updates the price card below the picker with that tier's price, and flips the form's submit to use that tier. When the product has 0 or 1 public policies, the buy page renders exactly as before — no UX change.`,
``,
`**Pre-selection logic.** \`?policy=<slug>\` deep-link wins (lets operators link buyers to a specific tier from marketing). Otherwise, any policy with \`metadata.highlight = true\` is pre-selected (and gets a "Most popular" gold pill). Otherwise, the cheapest tier is selected. Buyers can always change their selection before submitting.`,
``,
`**Policy "public" flag.** New \`public\` boolean column on the \`policies\` table (migration 0007, additive, defaults to 1 for existing rows). Admin can hide internal policies — Comp / press / internal team-seat templates — from the public buy page while still issuing under them via /v1/admin/licenses. Admin SPA gains a Show/Hide button on each policy row and a "On buy page" column.`,
``,
`**New endpoint:** \`GET /v1/products/:slug/policies\` (public, no auth). Returns the product (slug, name, description, base price) and an array of active+public policies with their buyer-facing fields (slug, name, description, price_sats, duration_seconds, max_machines, is_trial, entitlements, highlighted). Internal fields (id, tip recipient, raw metadata) are deliberately omitted.`,
``,
`**New invoice column:** \`policy_id\` (nullable, FK to policies). Stores the buyer's chosen tier on the invoice so issue_license_for_invoice can use it as the template. Pre-:27 invoices fall back to the legacy "default policy" pick (slug='default' or first active) — no breaking change.`,
``,
`**Updated APIs:**`,
` - POST /v1/purchase: accepts optional \`policy_slug\`. When set, the policy's \`price_sats_override\` becomes the base price (if defined), and the policy is persisted on the invoice. Validates the chosen tier is active+public; admins issuing comps stay on /v1/admin/licenses.`,
` - POST /v1/redeem: accepts optional \`policy_slug\`, same semantics. Works for free-license codes that should be issued under a specific tier.`,
` - GET /v1/discount-codes/preview: accepts optional \`policy_slug\` query param. Discount math is computed against the chosen tier's effective price; codes restricted to a different policy return \`{valid: false, reason: "wrong_tier"}\`.`,
` - POST /v1/admin/policies/:id/public: new admin endpoint, audit-logged as policy.set_public. Toggles the public flag.`,
``,
`**Code-applied-to-policy enforcement.** Discount codes have an \`applies_to_policy_id\` column from migration 0004; pre-:27 it was informational only. Now it's enforced in /v1/purchase, /v1/redeem, and /v1/discount-codes/preview: a code restricted to a specific tier is rejected on any other tier with a clear error message.`,
``,
`**Buy-page tier metadata conventions** (no schema change required):`,
` - \`metadata.description\` (string): short blurb shown on the tier card. ~1 sentence works best.`,
` - \`metadata.highlight\` (bool): true → "Most popular" gold pill + pre-selection.`,
`Both are optional. Existing policies without these keys render fine, just plainer.`,
``,
`**Database changes:** migration 0007_tiered_pricing.sql, additive only:`,
` - ALTER TABLE policies ADD COLUMN public INTEGER NOT NULL DEFAULT 1;`,
` - ALTER TABLE invoices ADD COLUMN policy_id TEXT REFERENCES policies(id);`,
` - CREATE INDEX idx_policies_public ON policies(public);`,
``,
`**Net effect.** Operators can run a real free + paid tier model from a single buy URL. Keysat itself can list keysat-free / keysat-pro / keysat-patron tiers from one /buy/keysat URL once the corresponding policies are created on the master Keysat. Foundation for the v0.3 entitlement-gating of recurring billing + Zaprite (Pro-only features).`,
``,
`Alpha-iteration revision 26 of v0.1.0 — anonymous-friendly checkout, editable discount codes, and a new "set flat price" code kind.`,
``,
`**Anonymous-friendly checkout.** Email is now genuinely optional on /buy/<slug>. Reworded label to "Email (optional)" and the hint to: "Useful only if you want a buyer reference for lost-key recovery. Skip it to pay anonymously — your license key is shown directly on this site either way." The form no longer enforces \`required\`. The success card now displays the invoice id ("Reference for support: <id>") in place of the email-based reference, so an anonymous buyer who needs help still has a concrete reference to give the seller.`,
``,
`**Edit discount codes.** New endpoint: PATCH /v1/admin/discount-codes/:id. Mutable fields are amount, max_uses, expires_at, description, referrer_label. Code string, kind, and product/policy scope are deliberately NOT editable — changing those would silently invalidate links already in circulation; operators should disable + create a new code instead. max_uses cannot be set below the current used_count. Audit-logged as discount_code.update.`,
``,
`Admin UI gains an Edit button next to Disable/Delete on each code row. Clicking opens an inline edit panel above the table pre-populated with the code's current values; Save PATCHes and reloads, Cancel closes without changes.`,
``,
`**New "set flat price" discount kind.** \`kind: 'set_price'\` lets an operator say "with this code, the buyer pays exactly N sats" regardless of base price. Useful for promo flat-rates ("everyone gets it for 5000 sats this week") and for regional pricing. If amount is greater than or equal to base, the code provides no benefit (final price = base). Wired through the same three places the other kinds live: purchase math, free-redeem path doesn't apply (only \`free_license\` skips BTCPay), and the public preview endpoint shows "Flat price applied: N sats." Validation: amount must be > 0 at create time. Existing kinds (\`percent\`, \`fixed_sats\`, \`free_license\`) unchanged.`,
``,
`Admin UI: the create-code Kind dropdown gains "Set flat price (in sats)". The Amount field's hint updates live as the operator changes Kind, so the meaning of the number is always obvious. Codes table shows "5,000 sats flat" for set_price entries (vs "5,000 sats off" for fixed_sats).`,
``,
`No DB schema changes since :25 — all changes are at the application layer.`,
``,
`Alpha-iteration revision 25 of v0.1.0 — admin Licenses tab actually shows licenses, plus thank-you page hardening and honest copy.`,
``,
`**Bug fix (high-impact UX bug):** the admin Licenses tab was rendering an empty search box with no auto-load, so issued licenses appeared invisible until the operator typed something into the search field. The backend already returns the 100 most-recent licenses when called with no filters; the UI just never called it. Tab now auto-loads recent licenses on open, with a Clear button to reset back to recent after a search. Empty-state copy is friendlier ("No licenses issued yet — once a buyer purchases or redeems, they appear here") instead of misleading.`,
``,
`**New endpoint:** GET /v1/admin/licenses/summary — returns aggregate counts {total, active, suspended, revoked, last_24h, last_7d}. Wires the Overview dashboard's "Active licenses" stat card to a real value (it was silently 404ing and showing ""). Cheap query, runs on every dashboard load.`,
``,
`**Buy-form copy honest fix:** the email-field hint claimed "we'll send your license key here after payment confirms" — but Keysat doesn't have SMTP delivery yet. Reworded to "Used as your buyer reference for support and lost-key recovery. Your license key is shown directly on this site after payment." Same change on the inline success card and the /thank-you success card. Email sending is a v0.2 build; until then the buy-page promise matches what actually happens. Email is still captured and stored on the license — just not actively sent.`,
``,
`**Thank-you page hardening for buyers who click "Return to Keysat" early:** BTCPay's processing screen has a Return button. Clicking it before the invoice settles lands the buyer on /thank-you?invoice_id=… while the license isn't yet issued. The polling loop:`,
` - Was 12 minutes hard-cap with a fixed 3s cadence — tight against Bitcoin block-time variance, especially on slow blocks.`,
` - Now: adaptive cadence (3s for first 2 min, 10s for 210 min, 30s for 1030 min) and a 30-min hard deadline. Saves bandwidth on the buyer's phone without missing slow blocks.`,
` - Improved waiting copy that explains what's happening and tells the buyer the URL is bookmark-friendly so they can close the tab and come back.`,
` - The pending card now displays the invoice id ("Reference for support: <id>") so a buyer who hits the deadline has something concrete to send the seller.`,
``,
`Net effect: buyers can safely click Return-to-Keysat early without missing the license. Operators see issued licenses in the admin without having to search. Buy-page promises now match implementation.`,
``,
`No DB schema changes since :24.`,
``,
`Alpha-iteration revision 24 of v0.1.0 — Apply-discount button on the buy page + delete discount codes from the admin UI.`,
``,
`Buy page (/buy/<slug>) — buyers can now click an "Apply" button next to the discount code input to preview the discount before committing. The price card updates with strikethrough on the original price, the new price, and a green tag showing the percent or sats off. If the code is a free_license type, the primary CTA flips from "Pay with Bitcoin" to "Redeem license" and skips the BTCPay path entirely on submit. Validation happens against a new public endpoint GET /v1/discount-codes/preview which checks existence/active/expiry/product/exhaustion and computes the discounted price WITHOUT consuming a redemption slot. Editing the code after Apply resets the price card.`,