diff --git a/KEYSAT_INTEGRATION.md b/KEYSAT_INTEGRATION.md index b86c738..bb39b5b 100644 --- a/KEYSAT_INTEGRATION.md +++ b/KEYSAT_INTEGRATION.md @@ -1178,6 +1178,57 @@ selling tiered plans typically have: - A pro / paid tier with several (`unlimited_*`, premium features) - Optional Patron / supporter tier with all of Pro plus a `patron` badge +### Entitlements catalog (v0.2.0:8+) + +Operators can declare a closed list of entitlements per product +in admin (Products → Edit → "Entitlements catalog"). Each entry has +three fields: + +``` +slug name description +core Core Past the activation screen, basic features. +ai_summaries AI summaries Auto-generate per-video summaries with GPT. +library_io Library I/O Bulk import/export of saved summaries. +``` + +Once a catalog exists for a product, two things change: + +1. **The policy editor switches** from a free-text textarea to a + click-to-toggle bubble picker that only offers entitlements from + the catalog. The daemon enforces this at write time too (closed + list). +2. **The buy page renders display names + descriptions** instead of + raw slugs. Buyers see "AI summaries" with the description as a + hover tooltip, never the underscore-laden `ai_summaries`. + +For your SDK integration, the catalog comes back on +`GET /v1/products//policies` (and equivalently +`Client.listPublicPolicies()` in all four SDKs): + +```ts +const { product, policies } = await client.listPublicPolicies(SLUG) +// product.entitlementsCatalog is EntitlementDef[]: +// [{ slug: 'ai_summaries', name: 'AI summaries', description: '...' }, ...] +// +// Use it to render an in-app tier picker that shows the same human- +// readable names the buy page does: +function entitlementLabel(slug: string): string { + const def = product.entitlementsCatalog.find((e) => e.slug === slug) + return def?.name || slug.replace(/_/g, ' ') +} +``` + +If the operator hasn't defined a catalog (legacy "free-text" mode), +the array is empty and you fall back to rendering the raw slugs — +or replacing underscores with spaces yourself for a quick polish. + +**Catalog stability rule**: once you ship gating logic that checks +for entitlement `"export"`, the operator's catalog and policy +references have to stay using `"export"`. Renaming the slug breaks +existing licenses (which carry the old slug in their signed +payload). Adding NEW entitlement slugs to the catalog is fine — +just not renaming or deleting ones that licenses already reference. + --- ## 9. Online validation (optional, recommended) diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts index 0a93a46..0d55241 100644 --- a/startos/versions/v0.2.0.ts +++ b/startos/versions/v0.2.0.ts @@ -58,6 +58,26 @@ const RELEASE_NOTES = [ // in RELEASE_NOTES above (the milestone). Subsequent revisions // append here. const ROUTINE_NOTES = [ + '0.2.0:8 — **Entitlements catalog on products.** Operators define each product\'s entitlements once with display names + descriptions; policies pick from that closed list with a click-to-toggle bubble picker; the buy page renders human-readable names ("AI summaries") with descriptions as tooltips, never the raw slug ("ai_summaries"). Existing products are auto-backfilled from the union of their policies\' current entitlements (with name = slug-with-underscores-stripped) — operator can edit afterward to add proper descriptions.', + '', + '**Admin UI changes.** Product create + edit forms gain an "Entitlements catalog" editor: repeating rows for slug + display name + description, with an "+ Add entitlement" button. Policy create + edit forms swap the free-text entitlements textarea for a row of clickable pill chips populated from the parent product\'s catalog — click each chip to toggle that entitlement on or off for the policy. Policies list table renders entitlement display names (resolved via catalog) instead of slugs.', + '', + '**Buy page rendering.** Tier cards now show display names with the description as a hover tooltip on each entitlement bullet. Falls back to raw-slug rendering for legacy entries that predate the catalog (no buy-page-side breakage on upgrade).', + '', + '**Closed-list enforcement.** Once a product has a non-empty catalog, policy create + update endpoints reject any entitlement slug that\'s not in the catalog with a clear error pointing at the right path ("add it to the product\'s entitlements catalog first"). Products without a catalog stay in legacy "free-text" mode where any string is accepted — back-compat preserved.', + '', + '**SDK support.** All four SDKs (`@keysat/licensing-client`, `keysat-licensing-client` Rust crate, `keysat-licensing-client` Python package, `keysat-client-go`) bumped to 0.3.0. `Client.listPublicPolicies()` response now includes `product.entitlementsCatalog` (camelCase TS / snake_case Rust + Python + Go) — an array of `{slug, name, description}` so SDK consumers\' in-app tier pickers can render the same human-readable names + tooltips the buy page does. Empty array on legacy products without a catalog.', + '', + '**Schema (migration 0014).** Adds `products.entitlements_catalog_json` (nullable). Auto-backfill per product: collect the distinct union of all entitlement slugs across the product\'s policies, build a catalog with `name = slug.replace("_", " ")` and empty description, write it. Products with no policy entitlements anywhere stay NULL (legacy mode preserved).', + '', + '**Documentation.** KEYSAT_INTEGRATION.md section 8 ("Picking entitlement names") gets a new subsection explaining the catalog: how the bubble picker works, how the buy page renders, how SDKs surface it, and the catalog-stability rule (renaming a slug breaks existing licenses).', + '', + '**Test count: 78** (was 77; +1 for `migration_0014_backfills_entitlements_catalog_from_policies`).', + '', + '**Upgrade path.** v0.2.0:7 → v0.2.0:8 is a drop-in. Migration 0014 is additive; auto-backfill runs at boot. No behavior change for operators who don\'t open the product editor — old free-text policy entitlements continue to work. Operators who open the editor see the catalog already populated from their policies\' existing entitlements; they can refine display names + add descriptions.', + '', + '**What\'s next (v0.2.0:9):** side-by-side card-grid policy authoring UI. The current "open a disclosure, fill a form, click Create, repeat" flow gets replaced by a tier-card grid where operators can see existing policies as buy-page-style cards alongside editable draft cards, with multiple drafts allowed simultaneously. Lands as its own focused release on top of this.', + '', '0.2.0:7 — Marketing-copy alignment. Package short and long descriptions now read "Bitcoin-native self-hosted licensing service for software creators" — matches keysat.xyz and the new positioning. Long description also calls out Zaprite (Bitcoin + cards), recurring subscriptions, and tier upgrades, all of which shipped in earlier :N revisions but weren\'t reflected in the registry listing. Same change applied to the daemon Cargo.toml description, repo READMEs, and the in-StartOS About panel for consistency. No code changes; pure copy.', '', '0.2.0:6 — **Recurring subs + trials + self-tier live refresh actually work now.** Major bug-and-UX-fix release driven by hands-on testing of v0.2.0:5. The recurring-sub feature shipped in :4 had a critical gap: buying a recurring policy issued a license but never created the corresponding subscription row, so the renewal worker never picked it up — purchases silently behaved like one-shots. The trial flow shipped with `trial_days` configurable in admin but the field had zero effect on the purchase path. And admin tier changes on the daemon\'s own license never propagated to the running daemon, making it impossible to test Creator-tier gates on the master Keysat. This release fixes all three plus a slate of UX papercuts found during testing.', @@ -157,7 +177,7 @@ const ROUTINE_NOTES = [ ].join('\n\n') export const v0_2_0 = VersionInfo.of({ - version: '0.2.0:7', + version: '0.2.0:8', releaseNotes: { en_US: ROUTINE_NOTES }, // No on-disk transformation needed — v0.2.0:0 is a label change. // SQLite-level migrations live separately under