11e30ffb212abc12891f3eefac94c250498d501d
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
094cf75e52 |
v0.2.0:20 — Multi-policy scope for discount codes
A discount code can now apply to a subset of policies on a product (e.g. "Patron and Pro but not Creator") instead of being limited to exactly one policy or the entire product. - Migration 0018 adds `applies_to_policy_ids_json` (nullable JSON array of policy ids). Legacy `applies_to_policy_id` stays as the singular fallback when the JSON column is empty/NULL. - `DiscountCode::allowed_policy_ids()` helper unifies multi + singular into one Vec. Purchase + preview scope checks consult it. - `find_applicable_featured_discount` now narrows multi-policy candidates in Rust (small candidate set; index-friendly SQL would require json_each, deferred). - Admin API: `POST /v1/admin/discount-codes` accepts `policy_slugs` (array) alongside the existing `policy_slug` (singular). Multi wins when both are present. PATCH does not allow scope edits — same rule as the singular field (disable + recreate to re-scope). - UI: pill multi-select replaces the policy dropdown on the create form. Edit modal's scope label renders the comma-separated list. UI + schema both back-compat: existing codes keep working unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4334a9f044 |
v0.2.0:16 — Launch-special discount codes + marketing bullets
Major feature release.
Featured (launch-special) discount codes:
- New 'featured' flag on discount_codes (migration 0017). When true,
the buy page renders a diagonal LAUNCH SPECIAL ribbon + slashed
original price + new price for every applicable tier. Purchase
endpoint auto-applies the discount for buyers who don't type a
code. Operator-typed codes still win.
- find_applicable_featured_discount repo helper: most-specific match
(policy > product > global), tiebreak by created_at.
- GET /v1/products/<slug>/policies now returns featured_discount per
policy with the post-discount price computed server-side. SDK
consumers + the dynamic pricing page get this for free.
Marketing bullets on policies:
- metadata.marketing_bullets — operator-controlled copy that renders
as additional checkmarks above the entitlement bullets on both the
admin grid tier card and the buy page tier. For things like 'Up
to 5 products' or 'BTCPay integration' that aren't real
entitlement gates.
- Authored via textarea on draft + edit policy forms.
UI:
- 'Most popular' checkbox now on the draft tier card (was edit-only).
- Discount codes tab grouped by product (matching Licenses /
Subscriptions tabs). Each code row gets a 'featured' badge when
flagged.
All 87 tests still pass. Migration is additive, no SDK changes,
backwards-compatible.
|
||
|
|
257669092b |
v0.2.0:11 + v0.2.0:12 — Archive, Settings, agent surface, machines redesign
Two release cycles prepared together: v0.2.0:11 (policy archive + safe- delete cleanup + brand-consistent confirm modals) and v0.2.0:12 (Settings tab + agent-friendly operator API + machines tab redesign + buyer-facing copy alignment). Highlights: - Migration 0015: policies.archived_at column. Archive button on tier cards; safe-delete relaxed to ignore revoked-license tombstones; renewal worker refuses archived policies. - Migration 0016: scoped_api_keys table. Four roles (read-only, license-issuer, support, full-admin) with bounded scopes. Master admin_api_key still works on every endpoint; scoped keys gated on endpoints wired through require_scope(). - New /v1/openapi.json — public, no auth. Curated OpenAPI 3.1 spec for agent / SDK discovery. - New Settings tab: Operator name + Payment providers panel + API keys management. Replaces 8 StartOS Actions (Zaprite all, BTCPay all, operator name, switch-provider). StartOS Actions pruned to 4 install-time essentials. - Machines tab rewritten: global default view grouped by product, filter pills with counts, quick-stats row, drill-down via new "Machines" button on each Licenses-tab row. New repo helper list_machines_admin joins machines x licenses x products server-side. - Branded confirmModal replaces every native window.confirm() call in the admin UI (7 callsites). - Enforce mode killed: KEYSAT_LICENSE_ENFORCE compile-time flag retired; daemon always boots; missing self-license -> Creator (free) tier. "Unlicensed" label gone from admin UI. - Zaprite gated on the new zaprite_payments entitlement (renamed from card_payments to reflect the broader gateway). - Creator code cap 5 -> 10. - KEYSAT_AGENT_GUIDE.md: auth, role-to-scope mapping, error envelope, webhook events, worked recipes. - Buyer-facing copy aligned with new positioning: "Bitcoin-native self-hosted software licensing" everywhere on production surfaces. - Cross-product safety section (Section 9a) added to KEYSAT_INTEGRATION.md. - 5 new API integration smoke tests covering OpenAPI, scoped API keys CRUD, role-elevation guard, and Zaprite-tier gating. Test count: 83 passing (was 78). All migration tests pass against 0015 and 0016 applied to populated DBs. |
||
|
|
68dfe7f6fc |
Product entitlements catalog (Phase 1: schema + admin + buy page)
Closes the request to make entitlements first-class on products
instead of free-text strings on policies. Operators declare the
closed list of entitlements a product offers — slug + display name
+ optional description — and policies pick from that list with a
click-to-toggle bubble UI. Buy page renders human-readable names
("AI summaries") with descriptions as tooltips, never the raw slug
("ai_summaries").
Schema (migration 0014):
- products.entitlements_catalog_json: nullable JSON column shaped
as [{slug, name, description}, ...]
- Auto-backfill on upgrade: for each existing product, derive a
catalog from the union of its policies' entitlement slugs, with
name = slug.replace('_', ' ') and empty description. Operators
can refine afterward.
- Products with no policy entitlements stay NULL (legacy
free-text mode preserved).
Server:
- Product struct gains entitlements_catalog: Option<Vec<EntitlementDef>>
- repo::set_product_entitlements_catalog (validates lowercase ASCII
slugs, uniqueness, defaults name to slug if empty)
- Product create/update API accept entitlements_catalog;
update uses double-Option PATCH shape so operators can clear
- Closed-list validation: when product has a non-empty catalog,
policy create + update reject any entitlement slug not in the
catalog with a clear error pointing at the right path
- /v1/products/<slug>/policies surfaces entitlements_catalog
in the product object so SDK consumers can render display
names client-side
- Buy page renders entitlement display names + description tooltips
on tier cards (falls back to raw slug for legacy entries that
predate the catalog)
Admin UI:
- New catalogEditor() helper (repeating slug/name/description rows
with add/remove buttons) embedded in product create + edit forms
- New entitlementBubblePicker() helper (click-to-toggle pill chips
showing display name with description tooltip)
- Policy create form: entitlements input swaps based on the chosen
product's catalog — bubble picker when catalog has entries,
legacy textarea otherwise. Rebuilds when operator changes
product.
- Policy edit modal: same bubble-picker-or-textarea swap, scoped
to the policy's product
- Policy list table: entitlement column shows display names
(resolved against the product's catalog) instead of slugs
Migration regression test verifies:
- Backfill correctly unions entitlements across all of a product's
policies, deduplicates, applies name = slug-with-underscores-as-
spaces transformation
- Products with no policy entitlements get NULL (not [])
- Manually-set catalog values round-trip
- Schema is otherwise FK-clean post-migration
Test count: 78 (was 77; +1 for migration_0014_backfills_*).
Phase 2 (SDK updates + integration doc + side-by-side card-grid
policy authoring UI) ships in follow-up commits before v0.2.0:8.
|
||
|
|
8ce78ab9d3 |
Tier upgrades Phase 1 — schema foundation (dormant)
First step of TIER_UPGRADES_DESIGN.md (Grant + me, parent folder).
Schema-only commit; Phases 2-6 (quote logic, buyer endpoints, admin
endpoints, admin UI, buyer surface) ship in follow-ups.
Migration 0013_tier_upgrades.sql:
1. ALTER TABLE policies ADD COLUMN tier_rank INTEGER. Operator-defined
ladder ordering — higher = better tier. NULL means the policy isn't
in any ladder (existing operators see no behavior change). The
buyer-facing upgrade endpoint will validate
target.tier_rank > current.tier_rank for upgrades, and the reverse
for downgrades. Index on (product_id, tier_rank) supports the
"list this product's policies in ladder order" query.
2. New tier_changes table — one row per upgrade/downgrade. Captures:
- from_policy_id / to_policy_id with FKs into policies
- direction ('upgrade' | 'downgrade', CHECK enforced)
- listed_currency + proration_charge_value (smallest unit) for the
pricing snapshot; invoice_id nullable so comp-mode admin changes
(skip_payment=true) can write a row without an invoice
- effective_at decoupled from created_at so downgrades on recurring
subs can be RECORDED immediately but TAKE EFFECT at cycle end
- actor ('buyer' | 'admin', CHECK enforced) + free-form reason
- Three indexes covering the obvious query paths: by license
(history view), by created_at (operator analytics), partial on
invoice_id WHERE NOT NULL (webhook-handler lookup of
"is this settling invoice a tier-change?").
Migration regression test (8 tests now in tests/migrations.rs, was 7):
- Existing pre-0013 fixtures untouched, tier_rank defaults to NULL.
- tier_changes accepts a row referencing pre-0013 license/policy/invoice.
- CHECK constraints fire: bad direction, bad actor, negative
proration_charge_value all rejected.
- assert_db_clean confirms no FK / integrity drift.
Drive-by: branding design doc (parent folder) bumps its migration
number from 0013 → 0014 to avoid a collision with this one.
Test count: 58 (was 57; +1 for migration_0013_adds_tier_upgrades).
|
||
|
|
9eba309a8f |
v0.2.0:2 — Zaprite payment provider + recurring subscriptions schema foundation
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. |
||
|
|
4251e96082 |
Migration 0011 — recurring subscriptions schema (committed, not published)
Foundation-only commit. Adds the storage shape for recurring-billing
licenses; daemon code that uses these tables (renewal worker,
validate-hot-path subscription branch, admin endpoints, buy-page
recurring rendering) lands in subsequent commits.
Schema changes (all additive):
- policies gains: is_recurring, renewal_period_days,
grace_period_days (default 7), trial_days (default 0).
- New table `subscriptions` — one row per subscription-backed
license (1:1 via license_id UNIQUE). Tracks the cycle state
machine: active / past_due / cancelled / lapsed.
- New table `subscription_invoices` — one row per renewal-cycle
invoice. Joins subscriptions to the existing invoices table.
UNIQUE(subscription_id, cycle_number) prevents double-billing
the same cycle.
Pricing snapshot (listed_currency / listed_value / period_days)
is FROZEN at subscription creation. Operator changing the
underlying policy's price doesn't affect existing subs; the next
renewal still bills the snapshotted amount. Per
RECURRING_SUBSCRIPTIONS_DESIGN.md.
Migration regression test (migration_0011_adds_subscriptions_without
_breaking_existing_data) seeds realistic fixtures pre-0011, applies
0011, asserts:
- existing policies default to non-recurring with grace=7,
trial=0
- new tables accept rows via FKs into pre-0011 license/policy/
invoice rows
- status CHECK rejects garbage values
- subscription_invoices UNIQUE(sub_id, cycle_number) prevents
duplicate cycle inserts
- foreign_key_check + integrity_check both clean post-migration
Test count: 39 (was 38). Tests all pass:
9 unit + 16 API + 4 crosscheck + 7 migration + 3 worker.
Defaults encoded:
- grace_period_days = 7 (per RECURRING_SUBSCRIPTIONS_DESIGN
open question 1; my recommended default)
- trial_days included as a column from day 1 (per open question
3; cheaper to ship now than migrate later)
- cancellation refund: not a schema concern — just stops next
charge, license stays valid through current cycle (per
open question 2; my recommended default)
If Grant comes back with different answers, the defaults can be
tuned via ALTER COLUMN DEFAULT in a follow-up migration. Existing
subscriptions wouldn't be affected (they snapshot grace_period_days
at creation in their policy_id reference, not directly in the
subscription row — this might need rethinking once the renewal
worker lands; flagged for the next pass).
Not bumped / published — operator-visible only once the daemon
code that uses these tables ships. Ready to publish whenever
Grant approves the open-question defaults.
|
||
|
|
d8fcb51d1c |
Multi-currency schema foundation (Phase 1 of MULTI_CURRENCY_DESIGN)
Migration 0010 adds the columns needed to price products + policies in something other than satoshis (USD, EUR, BTC at higher denoms) while keeping every existing operator's data behaviorally identical. This is the foundation work; admin UI write path, buy page rendering, and rate fetcher land in subsequent phases. See MULTI_CURRENCY_DESIGN.md at the parent licensing/ folder for the full design. Schema changes (all additive): - products gain price_currency (TEXT NOT NULL DEFAULT 'SAT') and price_value (INTEGER NOT NULL DEFAULT 0). Backfill copies price_sats → price_value on every existing row, so SAT-priced products carry their information identically through the migration. - policies gain price_currency_override (nullable, NULL = inherit from product) and price_value_override (nullable, mirrors the existing price_sats_override). - invoices gain four nullable columns: listed_currency, listed_value, exchange_rate_centibps, exchange_rate_source. NULL on every current row; populated by the daemon when an invoice is created against a fiat-priced product. - discount_codes gains discount_currency (DEFAULT 'SAT'). 'percent' codes are currency-agnostic; 'fixed_sats' and 'set_price' codes use this column to express "$10 off" or "set price to $25" against fiat-priced products. - New index idx_products_currency for future "list products by currency" admin views. Read path: - Product struct gains price_currency + price_value fields (#[serde(default)] for back-compat with any cached/persisted shapes that predate them). - row_to_product extracts the new columns; falls back to SAT/ price_sats if a row predates 0010 (defensive — migration always runs at boot, but no reason to crash if it didn't). - All four product SELECTs add the new columns. Write path (legacy SAT-only callers): - create_product dual-writes price_sats AND price_value to the same value, with price_currency = 'SAT'. - update_product dual-writes price_sats and price_value when the caller passes a new sat price. Migration regression test: - migration_0010_backfills_existing_products_to_sat seeds three products (free, $100, $2500-equivalent) and a policy with a sat override BEFORE 0010 runs, applies 0010, asserts every row ends up with price_currency = 'SAT' and price_value = price_sats. Catches any future change that breaks the backfill contract. - migration_0009_is_idempotent now pinned to 0009 by filename (was: "the last migration"). 0010+ are not idempotent (ALTER TABLE ADD COLUMN can't be retried in SQLite); the idempotency test is specifically for 0009 because that migration's whole point was being safely re-runnable. Test count: 33 (was 32; +1 migration_0010_backfills test). Decisions locked in (per MULTI_CURRENCY_DESIGN open questions): - Default currency on new products: SAT. Operators explicitly pick USD for fiat-priced products. - Multi-currency available to all tiers (NOT gated behind Pro/ Patron) — the right product call. - Rate source priority: Kraken → Coinbase → CoinGecko (lands in Phase 4 of the design). - Recurring subscriptions: SAT-priced subs charge the same sat amount each cycle (no rate adjustment needed); USD-priced subs re-quote each cycle so the dollar amount is stable. |
||
|
|
116ed0d1f8 |
v0.1.0:41 — second hotfix to migration 0009; migration regression tests
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. |
||
|
|
beedd07f07 | 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 | ||
|
|
6ac118ae70 |
v0.1.0:24 — Keysat licensing service end-to-end
Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages, discount codes, free-license redemption, Apply-discount UX, self-licensing, and v0.1.0 release notes. |