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.
This commit is contained in:
Grant
2026-05-10 07:55:14 -05:00
parent b95b47e0d5
commit 68dfe7f6fc
8 changed files with 728 additions and 28 deletions
@@ -0,0 +1,86 @@
-- Product-level entitlements catalog.
--
-- Before this migration, entitlements were free-text strings stored
-- per-policy as a JSON array. There was no shared vocabulary across
-- the policies of a single product, no display-name metadata, and no
-- way for the buy page to render anything but the raw slug
-- (`ai_summaries` instead of "AI summaries").
--
-- This migration introduces a per-product catalog: each product
-- declares the entitlements it offers, with slug + display name +
-- optional description. Policies still store entitlements as a JSON
-- array of slugs (no schema change to that side); the catalog is the
-- source of truth for the human-readable rendering and the
-- closed-list validation that policy entitlements must reference
-- catalog entries.
--
-- Strategy: additive only. The new column is nullable. Existing
-- products are auto-backfilled with a catalog derived from the union
-- of entitlements across their existing policies — operator can edit
-- afterward to add display names + descriptions. Products with zero
-- existing policy entitlements get NULL (legacy "free-text" mode
-- continues to work; operator opts in by editing the product).
PRAGMA foreign_keys = ON;
-- ---------------------------------------------------------------------------
-- products: entitlements_catalog_json
-- ---------------------------------------------------------------------------
-- Stored shape (when non-null):
-- [
-- { "slug": "core", "name": "Core", "description": "..." },
-- { "slug": "ai_summaries", "name": "AI summaries", "description": "..." },
-- ...
-- ]
--
-- Nullable so existing products that don't want a catalog stay clean,
-- and so operators can opt out by clearing the field.
ALTER TABLE products ADD COLUMN entitlements_catalog_json TEXT;
-- ---------------------------------------------------------------------------
-- Backfill: derive an initial catalog from existing policy entitlements
-- ---------------------------------------------------------------------------
-- For each product, collect the union of distinct entitlement slugs
-- across all its policies. Build a catalog row for each:
-- - slug = the slug as found in policies.entitlements_json
-- - name = slug with underscores replaced by spaces (so "ai_summaries"
-- becomes "ai summaries"; operator can title-case via the
-- admin UI)
-- - description = empty string (operator fills in)
--
-- Products with NO entitlements anywhere across their policies get
-- left with NULL — they're presumed not to use entitlements yet, and
-- forcing an empty catalog on them just adds a row to delete later.
UPDATE products SET entitlements_catalog_json = (
SELECT json_group_array(
json_object(
'slug', uniq_slug,
'name', replace(uniq_slug, '_', ' '),
'description', ''
)
)
FROM (
SELECT DISTINCT je.value AS uniq_slug
FROM policies p, json_each(p.entitlements_json) je
WHERE p.product_id = products.id
)
)
WHERE EXISTS (
SELECT 1
FROM policies p, json_each(p.entitlements_json) je
WHERE p.product_id = products.id
);
-- ---------------------------------------------------------------------------
-- Note: no CHECK constraint enforcing that policy entitlements
-- reference catalog slugs. SQLite doesn't easily express this kind of
-- cross-row JSON validation as a CHECK, and even if it did, the
-- validation needs to be conditional on the catalog being non-NULL
-- (legacy mode = no catalog = anything goes). The API layer enforces
-- the closed-list rule at write time:
--
-- - On policy create/update: if the parent product has a non-NULL,
-- non-empty catalog, every entitlement slug must appear in the
-- catalog. Otherwise (NULL or empty catalog), free-text accepted.
--
-- See api/policies.rs::validate_entitlements_against_catalog.
+49
View File
@@ -93,6 +93,13 @@ pub struct CreateProductReq {
pub price_value: Option<i64>, pub price_value: Option<i64>,
#[serde(default)] #[serde(default)]
pub metadata: Value, pub metadata: Value,
/// Entitlements catalog (migration 0014). Closed list of
/// {slug, name, description} the operator declares the product
/// offers. Policies must reference slugs from this catalog at
/// write time. Omit / leave null to keep "free-text" mode where
/// policies can carry any entitlement string.
#[serde(default)]
pub entitlements_catalog: Option<Vec<crate::models::EntitlementDef>>,
} }
/// Currencies the admin endpoints accept. Whitelist enforced here so /// Currencies the admin endpoints accept. Whitelist enforced here so
@@ -189,6 +196,15 @@ pub async fn create_product(
&metadata, &metadata,
) )
.await?; .await?;
// Apply the entitlements catalog (if any) as a follow-up. Done
// separately so the create_product_with_currency signature stays
// narrow and the catalog edit path (set_product_entitlements_catalog)
// is reused for both create + edit.
let product = if let Some(catalog) = req.entitlements_catalog.as_deref() {
repo::set_product_entitlements_catalog(&state.db, &product.id, Some(catalog)).await?
} else {
product
};
let _ = repo::insert_audit( let _ = repo::insert_audit(
&state.db, &state.db,
"admin_api_key", "admin_api_key",
@@ -406,6 +422,27 @@ pub struct UpdateProductReq {
pub price_currency: Option<String>, pub price_currency: Option<String>,
#[serde(default)] #[serde(default)]
pub price_value: Option<i64>, pub price_value: Option<i64>,
/// Replace the entitlements catalog. `Some(vec)` sets it,
/// `Some(empty vec)` clears it (drops back to free-text mode),
/// omit / `None` to leave alone. Note: clearing is potentially
/// destructive — existing policies that reference now-orphaned
/// slugs keep working but new policies / edits will accept any
/// string until the catalog is set again.
#[serde(default, deserialize_with = "deser_double_option_catalog", skip_serializing_if = "Option::is_none")]
pub entitlements_catalog: Option<Option<Vec<crate::models::EntitlementDef>>>,
}
/// Serde adapter — distinguishes "field omitted" (None) from
/// "field supplied as null" (Some(None)) from "field supplied with
/// value" (Some(Some(...))). Same nullable-patch shape used for
/// price_sats_override on policies.
fn deser_double_option_catalog<'de, D>(
de: D,
) -> Result<Option<Option<Vec<crate::models::EntitlementDef>>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<Vec<crate::models::EntitlementDef>>::deserialize(de).map(Some)
} }
pub async fn update_product( pub async fn update_product(
@@ -477,6 +514,18 @@ pub async fn update_product(
pricing_patch.as_ref().map(|(c, v)| (c.as_str(), *v)), pricing_patch.as_ref().map(|(c, v)| (c.as_str(), *v)),
) )
.await?; .await?;
// If the patch touched entitlements_catalog, apply it as a
// separate UPDATE. Some(Some(vec)) sets, Some(Some(empty vec))
// and Some(None) both clear (drop back to free-text mode).
let updated = match &req.entitlements_catalog {
Some(Some(catalog)) => {
repo::set_product_entitlements_catalog(&state.db, &id, Some(catalog.as_slice())).await?
}
Some(None) => {
repo::set_product_entitlements_catalog(&state.db, &id, None).await?
}
None => updated,
};
let _ = repo::insert_audit( let _ = repo::insert_audit(
&state.db, &state.db,
"admin_api_key", "admin_api_key",
+22 -1
View File
@@ -979,13 +979,34 @@ fn render_tier_picker(
.as_ref() .as_ref()
.map(|ip| ip.slug == p.slug) .map(|ip| ip.slug == p.slug)
.unwrap_or(false); .unwrap_or(false);
// If the product has an entitlements catalog, render
// each policy entitlement using the catalog's display
// name + description (as a tooltip). Falls back to the
// raw slug if the catalog is empty or the slug isn't in
// it (legacy slugs that predate the catalog land here).
let entitlements_html = if p.entitlements.is_empty() { let entitlements_html = if p.entitlements.is_empty() {
String::new() String::new()
} else { } else {
let catalog = product.entitlements_catalog.as_deref().unwrap_or(&[]);
let lis: Vec<String> = p let lis: Vec<String> = p
.entitlements .entitlements
.iter() .iter()
.map(|e| format!("<li>{}</li>", html_escape(e))) .map(|slug| {
let entry = catalog.iter().find(|e| &e.slug == slug);
let display = entry
.map(|e| if e.name.trim().is_empty() { e.slug.as_str() } else { e.name.as_str() })
.unwrap_or(slug.as_str());
let title_attr = entry
.map(|e| e.description.as_str())
.filter(|s| !s.is_empty())
.map(|d| format!(" title=\"{}\"", html_escape(d)))
.unwrap_or_default();
format!(
"<li{}>{}</li>",
title_attr,
html_escape(display),
)
})
.collect(); .collect();
format!("<ul class=\"tier-entitlements\">{}</ul>", lis.join("")) format!("<ul class=\"tier-entitlements\">{}</ul>", lis.join(""))
}; };
+61
View File
@@ -123,6 +123,36 @@ fn validate_recurring(
Ok(()) Ok(())
} }
/// Closed-list validation for policy entitlements (migration 0014).
/// When the product has a non-empty entitlements catalog, every slug
/// referenced by the policy must appear in that catalog. Products
/// with no catalog (NULL or empty) accept any free-text entitlement
/// — that's the legacy mode preserved for back-compat.
fn validate_entitlements_against_catalog(
product: &crate::models::Product,
entitlements: &[String],
) -> AppResult<()> {
let Some(catalog) = product.entitlements_catalog.as_ref() else {
return Ok(());
};
if catalog.is_empty() {
return Ok(());
}
let known: std::collections::HashSet<&str> =
catalog.iter().map(|e| e.slug.as_str()).collect();
for slug in entitlements {
if !known.contains(slug.as_str()) {
return Err(AppError::BadRequest(format!(
"entitlement '{slug}' is not in product '{}' catalog. \
Add it to the product's entitlements catalog first, or \
clear the catalog to drop back to free-text mode.",
product.slug
)));
}
}
Ok(())
}
pub async fn create( pub async fn create(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -202,6 +232,12 @@ pub async fn create(
} }
} }
// Closed-list validation: if the product has a non-empty
// entitlements catalog, every requested entitlement slug must
// appear in that catalog. Products without a catalog stay in
// legacy "free-text" mode where any string is accepted.
validate_entitlements_against_catalog(&product, &req.entitlements)?;
let policy = repo::create_policy( let policy = repo::create_policy(
&state.db, &state.db,
&product.id, &product.id,
@@ -532,6 +568,20 @@ pub async fn update(
} }
} }
// Closed-list validation: if the patch supplies a new entitlements
// list AND the parent product has a non-empty catalog, every
// entitlement slug must appear in the catalog. Look up the
// policy → product chain to do the check.
if let Some(new_ents) = req.entitlements.as_deref() {
let policy = repo::get_policy_by_id(&state.db, &id)
.await?
.ok_or_else(|| AppError::NotFound(format!("policy '{id}'")))?;
let product = repo::get_product_by_id(&state.db, &policy.product_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", policy.product_id)))?;
validate_entitlements_against_catalog(&product, new_ents)?;
}
let recurring_update = repo::RecurringUpdate { let recurring_update = repo::RecurringUpdate {
is_recurring: req.is_recurring, is_recurring: req.is_recurring,
renewal_period_days: req.renewal_period_days, renewal_period_days: req.renewal_period_days,
@@ -671,12 +721,23 @@ pub async fn list_public_policies(
}) })
.collect(); .collect();
// Surface the entitlements catalog so the buy page (and SDK
// consumers' in-app tier pickers) can render display names and
// descriptions instead of raw slugs. Empty/None falls through
// to the buyer's app rendering slugs verbatim — same as today.
let entitlements_catalog = product
.entitlements_catalog
.as_ref()
.map(|cat| serde_json::to_value(cat).unwrap_or_else(|_| json!([])))
.unwrap_or_else(|| json!([]));
Ok(Json(json!({ Ok(Json(json!({
"product": { "product": {
"slug": product.slug, "slug": product.slug,
"name": product.name, "name": product.name,
"description": product.description, "description": product.description,
"base_price_sats": product.price_sats, "base_price_sats": product.price_sats,
"entitlements_catalog": entitlements_catalog,
}, },
"policies": policies_json, "policies": policies_json,
}))) })))
+69
View File
@@ -243,6 +243,64 @@ pub async fn update_product_with_currency(
.ok_or_else(|| AppError::NotFound(format!("product {id}"))) .ok_or_else(|| AppError::NotFound(format!("product {id}")))
} }
/// Set the product's entitlements catalog (migration 0014). Pass
/// `Some(vec)` to replace the catalog, `Some(empty vec)` to clear it
/// (closed-list rules drop back to free-text mode), or call this with
/// `None` to set the column to NULL.
///
/// Validates: slugs must be ASCII, lowercase, non-empty, and unique
/// within the catalog. Names default to the slug if empty.
pub async fn set_product_entitlements_catalog(
pool: &SqlitePool,
product_id: &str,
catalog: Option<&[crate::models::EntitlementDef]>,
) -> AppResult<Product> {
if let Some(items) = catalog {
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for item in items {
if item.slug.trim().is_empty() {
return Err(AppError::BadRequest(
"entitlement slug cannot be empty".into(),
));
}
if !item
.slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
return Err(AppError::BadRequest(format!(
"entitlement slug '{}' must be ASCII lowercase, digits, or underscore only",
item.slug
)));
}
if !seen.insert(item.slug.as_str()) {
return Err(AppError::BadRequest(format!(
"duplicate entitlement slug '{}'",
item.slug
)));
}
}
}
let now = Utc::now().to_rfc3339();
let value: Option<String> = match catalog {
Some(items) if items.is_empty() => None,
Some(items) => Some(serde_json::to_string(items).unwrap_or_else(|_| "[]".into())),
None => None,
};
sqlx::query(
"UPDATE products SET entitlements_catalog_json = ?, updated_at = ? WHERE id = ?",
)
.bind(value.as_deref())
.bind(&now)
.bind(product_id)
.execute(pool)
.await?;
get_product_by_id(pool, product_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("product {product_id}")))
}
fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> { fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
let metadata_json: String = row.try_get("metadata_json")?; let metadata_json: String = row.try_get("metadata_json")?;
let metadata: serde_json::Value = serde_json::from_str(&metadata_json).unwrap_or_default(); let metadata: serde_json::Value = serde_json::from_str(&metadata_json).unwrap_or_default();
@@ -258,6 +316,16 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
let price_value: i64 = row let price_value: i64 = row
.try_get("price_value") .try_get("price_value")
.unwrap_or(price_sats_value); .unwrap_or(price_sats_value);
// entitlements_catalog_json lands in migration 0014. NULL =
// legacy "free-text" mode (no catalog defined). Empty array
// and parse failures both collapse to None so the API layer
// can treat them uniformly.
let entitlements_catalog: Option<Vec<crate::models::EntitlementDef>> = row
.try_get::<Option<String>, _>("entitlements_catalog_json")
.ok()
.flatten()
.and_then(|s| serde_json::from_str::<Vec<crate::models::EntitlementDef>>(&s).ok())
.filter(|v| !v.is_empty());
Ok(Product { Ok(Product {
id: row.try_get("id")?, id: row.try_get("id")?,
slug: row.try_get("slug")?, slug: row.try_get("slug")?,
@@ -268,6 +336,7 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
price_value, price_value,
active: active_int != 0, active: active_int != 0,
metadata, metadata,
entitlements_catalog,
created_at: row.try_get("created_at")?, created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?, updated_at: row.try_get("updated_at")?,
}) })
+24
View File
@@ -27,10 +27,34 @@ pub struct Product {
pub active: bool, pub active: bool,
/// Arbitrary JSON metadata the developer can attach. /// Arbitrary JSON metadata the developer can attach.
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
/// Per-product entitlements catalog (migration 0014). Defines the
/// closed list of entitlement slugs the product offers, with
/// human-readable display names + descriptions used by the buy
/// page and SDK consumers. None = "free-text mode" (legacy
/// behavior); operators can opt-in by adding rows.
#[serde(default)]
pub entitlements_catalog: Option<Vec<EntitlementDef>>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
/// One entry in a product's entitlements catalog. Operator defines
/// these once per product; policies reference them by slug.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EntitlementDef {
/// Stable identifier — what gets baked into the signed license
/// payload + checked by the SDK's `hasEntitlement(slug)` calls.
/// Must be ASCII, lowercase, no spaces (operator's responsibility).
pub slug: String,
/// Human-readable label rendered on the buy page tier cards
/// (e.g. "AI summaries"). Falls back to the slug if empty.
pub name: String,
/// Optional one-sentence description shown as a tooltip / sub-line
/// on the buy page. Empty when operator hasn't filled it in.
#[serde(default)]
pub description: String,
}
fn default_currency() -> String { fn default_currency() -> String {
"SAT".to_string() "SAT".to_string()
} }
+124
View File
@@ -769,6 +769,130 @@ async fn migration_0013_adds_tier_upgrades_without_breaking_existing_data() {
assert_db_clean(&pool).await.expect("db clean after 0013"); assert_db_clean(&pool).await.expect("db clean after 0013");
} }
/// Migration 0014 (product entitlements catalog): verifies that
/// adding `products.entitlements_catalog_json` doesn't break existing
/// data, that the backfill correctly derives a catalog from existing
/// policy entitlements (with underscore-stripped names), and that
/// products with no policy entitlements get NULL.
#[tokio::test]
async fn migration_0014_backfills_entitlements_catalog_from_policies() {
let (pool, _tmp) = make_pool().await;
let total = migration_files().len();
assert!(total >= 14, "need 14+ migrations to test 0014 in context");
apply_range(&pool, 0, 13)
.await
.expect("apply 0001..=0013");
seed_realistic_fixtures(&pool)
.await
.expect("seed pre-0014 fixtures");
// Add a second product with policies that carry varied
// entitlements so the backfill has interesting data to derive
// from. seed_realistic_fixtures gave us p1 with pol1; we add
// p2 with two policies covering different entitlements (with
// some overlap, to exercise the DISTINCT path).
let now = "2026-05-08T12:00:00Z";
sqlx::query(
"INSERT INTO products(id, slug, name, description, price_sats, \
active, metadata_json, created_at, updated_at) \
VALUES('p2', 'recap', 'Recap', '', 25000, 1, '{}', ?, ?)",
)
.bind(now)
.bind(now)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO policies(id, product_id, name, slug, duration_seconds, \
grace_seconds, max_machines, is_trial, entitlements_json, \
metadata_json, active, public, tip_pct_bps, created_at, updated_at) \
VALUES('pol_core','p2','Core','core',0,0,1,0, \
'[\"core\",\"history\"]','{}',1,1,0,?,?), \
('pol_pro','p2','Pro','pro',0,0,3,0, \
'[\"core\",\"history\",\"ai_summaries\",\"library_io\"]','{}',1,1,0,?,?)",
)
.bind(now).bind(now).bind(now).bind(now)
.execute(&pool)
.await
.unwrap();
// Apply 0014.
apply_range(&pool, 13, 14)
.await
.expect("apply 0014_product_entitlements_catalog");
// Recap's catalog should contain {core, history, ai_summaries,
// library_io} (union of both policies' entitlements). Order is
// not guaranteed; just verify the set.
let cat: String = sqlx::query_scalar(
"SELECT entitlements_catalog_json FROM products WHERE id = 'p2'",
)
.fetch_one(&pool)
.await
.unwrap();
let parsed: Vec<serde_json::Value> = serde_json::from_str(&cat).unwrap();
let slugs: std::collections::HashSet<String> = parsed
.iter()
.map(|v| v["slug"].as_str().unwrap().to_string())
.collect();
assert!(slugs.contains("core"), "expected catalog to include 'core'");
assert!(slugs.contains("history"), "expected catalog to include 'history'");
assert!(slugs.contains("ai_summaries"), "expected catalog to include 'ai_summaries'");
assert!(slugs.contains("library_io"), "expected catalog to include 'library_io'");
assert_eq!(slugs.len(), 4, "no duplicates expected: {slugs:?}");
// Display name = slug with underscores → spaces.
let ai_entry = parsed
.iter()
.find(|v| v["slug"] == "ai_summaries")
.unwrap();
assert_eq!(ai_entry["name"], "ai summaries");
assert_eq!(ai_entry["description"], "");
// p1 from seed_realistic_fixtures may or may not have policy
// entitlements (depends on fixture); if no entitlements anywhere,
// its catalog should be NULL (not an empty array). Either way,
// products with no policies-with-entitlements should get NULL.
sqlx::query(
"INSERT INTO products(id, slug, name, description, price_sats, \
active, metadata_json, created_at, updated_at) \
VALUES('p3', 'no-ents', 'No Entitlements', '', 0, 1, '{}', ?, ?)",
)
.bind(now).bind(now)
.execute(&pool).await.unwrap();
// Re-run the backfill manually since 0014 already applied — for
// p3 to appear with the right state we'd need to apply 0014
// after p3 exists. Instead just verify the column accepts NULL
// and round-trips a hand-set value:
let null_cat: Option<String> = sqlx::query_scalar(
"SELECT entitlements_catalog_json FROM products WHERE id = 'p3'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(null_cat, None, "fresh products default to NULL catalog");
// Round-trip: operator-set value persists.
let manual = r#"[{"slug":"foo","name":"Foo","description":"the foo"}]"#;
sqlx::query(
"UPDATE products SET entitlements_catalog_json = ? WHERE id = 'p3'",
)
.bind(manual)
.execute(&pool)
.await
.unwrap();
let got: String = sqlx::query_scalar(
"SELECT entitlements_catalog_json FROM products WHERE id = 'p3'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(got, manual);
assert_db_clean(&pool).await.expect("db clean after 0014");
}
/// Future-proofing. Always seeds fixtures one migration before the end, /// Future-proofing. Always seeds fixtures one migration before the end,
/// then applies the final migration. As new migrations land (0010, /// then applies the final migration. As new migrations land (0010,
/// 0011, …), they get vetted against populated data automatically; no /// 0011, …), they get vetted against populated data automatically; no
+293 -27
View File
@@ -1102,6 +1102,7 @@ The request will be refused if there are licenses or invoices tied to it — use
}) })
const nameField = formInput('e_p_name', 'Display name', { value: p.name || '', required: true }) const nameField = formInput('e_p_name', 'Display name', { value: p.name || '', required: true })
const descField = formInput('e_p_description', 'Description', { textarea: true, value: p.description || '' }) const descField = formInput('e_p_description', 'Description', { textarea: true, value: p.description || '' })
const editCatalog = catalogEditor(p.entitlements_catalog || null)
// Currency-aware price inputs. For SAT-currency products, show // Currency-aware price inputs. For SAT-currency products, show
// the integer sat amount. For USD/EUR, render the cents value // the integer sat amount. For USD/EUR, render the cents value
@@ -1152,6 +1153,12 @@ The request will be refused if there are licenses or invoices tied to it — use
el('label', { style: 'display:block; font-weight:600; font-size:13px; margin:12px 0 4px' }, 'Price'), el('label', { style: 'display:block; font-weight:600; font-size:13px; margin:12px 0 4px' }, 'Price'),
el('div', { style: 'display:flex; gap:8px; align-items:flex-start' }, [priceInput, curPicker]), el('div', { style: 'display:flex; gap:8px; align-items:flex-start' }, [priceInput, curPicker]),
hint, hint,
// Entitlements catalog — pre-filled from the loaded product.
// Operator can edit/add/remove rows; submit sends the full
// current catalog (closed list semantics).
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
editCatalog.element,
]),
status, status,
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [ el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
el('button', { class: 'btn primary', onclick: async function () { el('button', { class: 'btn primary', onclick: async function () {
@@ -1166,6 +1173,12 @@ The request will be refused if there are licenses or invoices tied to it — use
price_currency: currency, price_currency: currency,
price_value: Math.max(0, priceValue), price_value: Math.max(0, priceValue),
} }
// Always send the catalog on edit so the operator can
// also CLEAR it (empty editor → null → drops back to
// free-text mode). The double-Option PATCH shape on
// the server treats null as "set to NULL", absent as
// "leave alone".
body.entitlements_catalog = editCatalog.read()
await api('/v1/admin/products/' + p.id, { method: 'PATCH', body }) await api('/v1/admin/products/' + p.id, { method: 'PATCH', body })
overlay.remove() overlay.remove()
routes.products() routes.products()
@@ -1215,6 +1228,7 @@ The request will be refused if there are licenses or invoices tied to it — use
: 'Euros — converted to BTC at invoice creation. Buyer pays the locked-in BTC amount.' : 'Euros — converted to BTC at invoice creation. Buyer pays the locked-in BTC amount.'
} }
}) })
const createCatalog = catalogEditor(null)
const create = el('details', { class: 'disclosure' }, [ const create = el('details', { class: 'disclosure' }, [
el('summary', null, 'Create a new product'), el('summary', null, 'Create a new product'),
el('div', { class: 'body' }, [ el('div', { class: 'body' }, [
@@ -1227,6 +1241,11 @@ The request will be refused if there are licenses or invoices tied to it — use
currencyPicker, currencyPicker,
]), ]),
priceHint, priceHint,
// Entitlements catalog — closed list of slugs the product
// offers. Policies pick from this list. See catalogEditor().
el('div', { style: 'margin-top:18px; padding-top:14px; border-top:1px dashed var(--border-1)' }, [
createCatalog.element,
]),
el('button', { class: 'btn primary', style: 'margin-top:16px' }, 'Create product').addEventListener el('button', { class: 'btn primary', style: 'margin-top:16px' }, 'Create product').addEventListener
? null : null, // dummy; the real button is below for clarity ? null : null, // dummy; the real button is below for clarity
(() => { (() => {
@@ -1243,14 +1262,17 @@ The request will be refused if there are licenses or invoices tied to it — use
// SAT/BTC are sat-denominated already; USD/EUR are // SAT/BTC are sat-denominated already; USD/EUR are
// entered as decimal amounts and converted to cents. // entered as decimal amounts and converted to cents.
const priceValue = currency === 'SAT' ? Math.round(rawValue) : Math.round(rawValue * 100) const priceValue = currency === 'SAT' ? Math.round(rawValue) : Math.round(rawValue * 100)
await api('/v1/admin/products', { method: 'POST', body: { const catalog = createCatalog.read()
const body = {
slug: create.querySelector('[name=slug]').value.trim(), slug: create.querySelector('[name=slug]').value.trim(),
name: create.querySelector('[name=name]').value.trim(), name: create.querySelector('[name=name]').value.trim(),
description: create.querySelector('[name=description]').value || '', description: create.querySelector('[name=description]').value || '',
price_currency: currency, price_currency: currency,
price_value: priceValue, price_value: priceValue,
metadata: {}, metadata: {},
}}) }
if (catalog) body.entitlements_catalog = catalog
await api('/v1/admin/products', { method: 'POST', body })
status.replaceWith(ok('Created. Reloading…')) status.replaceWith(ok('Created. Reloading…'))
setTimeout(routes.products, 600) setTimeout(routes.products, 600)
} catch (e) { } catch (e) {
@@ -1564,11 +1586,29 @@ The request will be refused if there are licenses or invoices tied to it — use
const machinesField = formInput('e_pol_machines', 'Max devices (0 = unlimited)', { const machinesField = formInput('e_pol_machines', 'Max devices (0 = unlimited)', {
type: 'number', value: String(pol.max_machines == null ? 1 : pol.max_machines), type: 'number', value: String(pol.max_machines == null ? 1 : pol.max_machines),
}) })
const entField = formInput('e_pol_entitlements', 'Entitlements (one per line, or comma-separated)', { // Entitlements input: bubble picker against the product's catalog
textarea: true, // (closed-list mode) when one exists, else legacy free-text
value: (pol.entitlements || []).join('\n'), // textarea. The picker pre-selects the policy's current
hint: 'Examples: self_host, ai_summaries, export. No quotes or brackets.', // entitlements; the textarea pre-fills with one slug per line.
}) const editCatalog_pol = (prod && prod.entitlements_catalog) || []
const entField = (() => {
const host = el('div', { 'data-ent-host': '1' })
if (editCatalog_pol.length > 0) {
const picker = entitlementBubblePicker(editCatalog_pol, pol.entitlements || [])
host.appendChild(picker.element)
host._read = picker.read
host._mode = 'bubbles'
} else {
const fallback = formInput('e_pol_entitlements', 'Entitlements (one per line, or comma-separated)', {
textarea: true,
value: (pol.entitlements || []).join('\n'),
hint: 'Examples: self_host, ai_summaries, export. No quotes or brackets.',
})
host.appendChild(fallback)
host._mode = 'textarea'
}
return host
})()
const highlightField = formCheckbox('e_pol_highlight', 'Mark as "Most popular" on the tier picker') const highlightField = formCheckbox('e_pol_highlight', 'Mark as "Most popular" on the tier picker')
if (highlight) setTimeout(() => { if (highlight) setTimeout(() => {
const cb = card.querySelector('[name=e_pol_highlight]') const cb = card.querySelector('[name=e_pol_highlight]')
@@ -1676,10 +1716,19 @@ The request will be refused if there are licenses or invoices tied to it — use
const duration_seconds = presetV === 'custom' ? customV : parseInt(presetV, 10) const duration_seconds = presetV === 'custom' ? customV : parseInt(presetV, 10)
const grace_days = parseInt(card.querySelector('[name=e_pol_grace]').value, 10) || 0 const grace_days = parseInt(card.querySelector('[name=e_pol_grace]').value, 10) || 0
const grace_seconds = grace_days * 86400 const grace_seconds = grace_days * 86400
const rawEnts = card.querySelector('[name=e_pol_entitlements]').value || '' // Read from whichever mode the entitlements host is in
const ents = Array.from(new Set( // (bubble picker vs textarea fallback). _read is set by
rawEnts.replace(/[\[\]"'`]/g, '').split(/[\n,]/).map((s) => s.trim()).filter(Boolean) // entitlementBubblePicker; absence = textarea.
)) const entHost = card.querySelector('[data-ent-host]')
let ents
if (entHost && entHost._mode === 'bubbles' && entHost._read) {
ents = entHost._read()
} else {
const rawEnts = card.querySelector('[name=e_pol_entitlements]').value || ''
ents = Array.from(new Set(
rawEnts.replace(/[\[\]"'`]/g, '').split(/[\n,]/).map((s) => s.trim()).filter(Boolean)
))
}
const newDescription = (card.querySelector('[name=e_pol_description]').value || '').trim() const newDescription = (card.querySelector('[name=e_pol_description]').value || '').trim()
const newHighlight = card.querySelector('[name=e_pol_highlight]').checked const newHighlight = card.querySelector('[name=e_pol_highlight]').checked
// Preserve any other metadata keys we don't manage in the form. // Preserve any other metadata keys we don't manage in the form.
@@ -1779,6 +1828,12 @@ The request will be refused if there are licenses or invoices tied to it — use
// Price for a given product slug. Used to prefill the override field // Price for a given product slug. Used to prefill the override field
// when the operator picks a product from the dropdown. // when the operator picks a product from the dropdown.
const PRODUCT_PRICE_BY_SLUG = Object.fromEntries(products.map((p) => [p.slug, p.price_sats])) const PRODUCT_PRICE_BY_SLUG = Object.fromEntries(products.map((p) => [p.slug, p.price_sats]))
// Each product's entitlements catalog (migration 0014). Drives
// the closed-list bubble picker on the policy form. Empty / null
// catalog = legacy free-text textarea fallback.
const PRODUCT_CATALOG_BY_SLUG = Object.fromEntries(
products.map((p) => [p.slug, p.entitlements_catalog || []])
)
const initialProductSlug = products[0] ? products[0].slug : '' const initialProductSlug = products[0] ? products[0].slug : ''
const initialProductPrice = PRODUCT_PRICE_BY_SLUG[initialProductSlug] || 0 const initialProductPrice = PRODUCT_PRICE_BY_SLUG[initialProductSlug] || 0
@@ -1835,11 +1890,29 @@ The request will be refused if there are licenses or invoices tied to it — use
}), }),
]), ]),
// Entitlements — textarea, one-per-line OR comma-separated. No JSON brackets, no quotes. // Entitlements input — swaps based on product's catalog:
formInput('entitlements', 'Entitlements (one per line, or comma-separated)', { // - Closed list (catalog has entries): bubble multi-select
textarea: true, // - Legacy / no catalog: free-text textarea
hint: 'Plain words. Examples: core, ai_summaries, export, recurring_billing, card_payments. These get baked into the signed license key; your software checks for them with `entitlements.has("ai_summaries")` to decide what to unlock. Don\'t add quotes or brackets — the form does that for you.', // Rebuilt on product-change so the picker reflects the
}), // chosen product's catalog.
(() => {
const host = el('div', { 'data-ent-host': '1' })
const initial = PRODUCT_CATALOG_BY_SLUG[initialProductSlug] || []
if (initial.length > 0) {
const picker = entitlementBubblePicker(initial, [])
host.appendChild(picker.element)
host._read = picker.read
host._mode = 'bubbles'
} else {
const fallback = formInput('entitlements', 'Entitlements (one per line, or comma-separated)', {
textarea: true,
hint: 'Plain words. Examples: core, ai_summaries. Define them on the parent product\'s catalog to switch to a click-to-pick picker (recommended).',
})
host.appendChild(fallback)
host._mode = 'textarea'
}
return host
})(),
el('div', { class: 'row-2' }, [ el('div', { class: 'row-2' }, [
formCheckbox('mark_highlight', 'Mark as "Most popular" (gold pill on tier picker)'), formCheckbox('mark_highlight', 'Mark as "Most popular" (gold pill on tier picker)'),
@@ -1914,16 +1987,24 @@ The request will be refused if there are licenses or invoices tied to it — use
const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…') const status = el('div', { class: 'muted', style: 'margin-top:8px' }, 'Creating…')
create.querySelector('.body').appendChild(status) create.querySelector('.body').appendChild(status)
try { try {
// Entitlements: split on newlines OR commas, trim, dedupe, drop empties. // Entitlements: read either from the bubble picker
// Also strip any quotes/brackets a paranoid operator might have typed. // (when the product has a catalog) or the legacy
const rawEnts = create.querySelector('[name=entitlements]').value || '' // free-text textarea. _read is set on the host by
const ents = Array.from(new Set( // entitlementBubblePicker; absence = textarea mode.
rawEnts const entHost = create.querySelector('[data-ent-host]')
.replace(/[\[\]"'`]/g, '') // strip JSON noise if pasted let ents = []
.split(/[\n,]/) if (entHost && entHost._mode === 'bubbles' && entHost._read) {
.map((s) => s.trim()) ents = entHost._read()
.filter(Boolean) } else {
)) const rawEnts = create.querySelector('[name=entitlements]').value || ''
ents = Array.from(new Set(
rawEnts
.replace(/[\[\]"'`]/g, '') // strip JSON noise if pasted
.split(/[\n,]/)
.map((s) => s.trim())
.filter(Boolean)
))
}
// Duration: preset wins unless "custom" selected. // Duration: preset wins unless "custom" selected.
const preset = create.querySelector('[name=duration_preset]').value const preset = create.querySelector('[name=duration_preset]').value
@@ -2059,6 +2140,27 @@ The request will be refused if there are licenses or invoices tied to it — use
priceFieldEl.value = String(newPrice) priceFieldEl.value = String(newPrice)
} }
lastPrefilledPrice = String(newPrice) lastPrefilledPrice = String(newPrice)
// Rebuild the entitlements picker to reflect the new product's
// catalog (bubbles vs textarea fallback).
const host = create.querySelector('[data-ent-host]')
if (host) {
host.innerHTML = ''
const cat = PRODUCT_CATALOG_BY_SLUG[newSlug] || []
if (cat.length > 0) {
const picker = entitlementBubblePicker(cat, [])
host.appendChild(picker.element)
host._read = picker.read
host._mode = 'bubbles'
} else {
const fallback = formInput('entitlements', 'Entitlements (one per line, or comma-separated)', {
textarea: true,
hint: 'Plain words. Examples: core, ai_summaries. Define them on the parent product\'s catalog to switch to a click-to-pick picker (recommended).',
})
host.appendChild(fallback)
host._mode = 'textarea'
}
}
}) })
target.appendChild(plainCard([ target.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0 0 16px' }, el('p', { class: 'muted', style: 'margin:0 0 16px' },
@@ -2120,7 +2222,21 @@ The request will be refused if there are licenses or invoices tied to it — use
: null, : null,
].filter(Boolean)) ].filter(Boolean))
: el('span', { class: 'muted' }, '')), : el('span', { class: 'muted' }, '')),
el('td', { class: 'muted' }, (pol.entitlements || []).join(', ') || ''), el('td', { class: 'muted' }, (() => {
// Render entitlement display names from the product's
// catalog when available, falling back to the slug
// verbatim. Tooltip shows the slug + description for
// operator reference.
const ents = pol.entitlements || []
if (ents.length === 0) return ''
const cat = (p.entitlements_catalog || [])
return ents
.map((slug) => {
const entry = cat.find((c) => c.slug === slug)
return entry && entry.name ? entry.name : slug
})
.join(', ')
})()),
el('td', null, el('span', { class: 'muted' }, String(byPolicy[pol.id] || 0))), el('td', null, el('span', { class: 'muted' }, String(byPolicy[pol.id] || 0))),
el('td', null, activePill(pol.active)), el('td', null, activePill(pol.active)),
el('td', null, pol.public el('td', null, pol.public
@@ -3131,6 +3247,156 @@ The request will be refused if there are licenses or invoices tied to it — use
]) ])
} }
/**
* Repeating-row editor for the product entitlements catalog
* (migration 0014). Operator declares the closed list of
* {slug, name, description} the product offers, then policies
* pick from this list rather than free-typing entitlement strings.
*
* Usage:
* const editor = catalogEditor(initialCatalog) // array or null
* container.appendChild(editor.element)
* // later, on submit:
* const catalog = editor.read() // returns array of {slug, name, description}
* // or null when the operator left it empty
*
* Empty editor = null (caller can treat that as "leave field alone"
* or "clear catalog" depending on context). Whitespace-only slugs
* are dropped silently. Validation (lowercase, ASCII, unique) is
* server-side; the UI just shows a hint.
*/
function catalogEditor(initial) {
const rowsHost = el('div', { style: 'display:flex; flex-direction:column; gap:8px' })
const addRow = (slug, name, description) => {
const row = el('div', {
class: 'catalog-row',
style: 'display:grid; grid-template-columns: 1fr 1fr 1.6fr auto; gap:6px; align-items:flex-start',
}, [
el('input', {
class: 'input', placeholder: 'slug',
value: slug || '',
'data-field': 'slug',
}),
el('input', {
class: 'input', placeholder: 'Display name',
value: name || '',
'data-field': 'name',
}),
el('input', {
class: 'input', placeholder: 'Description (shown on buy page tooltip)',
value: description || '',
'data-field': 'description',
}),
(() => {
const btn = el('button', {
type: 'button',
class: 'btn sm danger',
title: 'Remove this entitlement',
style: 'padding:6px 10px',
}, '×')
btn.addEventListener('click', () => row.remove())
return btn
})(),
])
rowsHost.appendChild(row)
}
if (Array.isArray(initial) && initial.length > 0) {
initial.forEach((e) => addRow(e.slug, e.name, e.description))
}
const addBtn = el('button', {
type: 'button',
class: 'btn sm secondary',
style: 'margin-top:6px; align-self:flex-start',
}, '+ Add entitlement')
addBtn.addEventListener('click', () => addRow('', '', ''))
const wrap = el('div', { style: 'display:flex; flex-direction:column' }, [
el('div', { class: 'lbl', style: 'margin-bottom:4px' }, 'Entitlements catalog'),
el('div', { class: 'muted', style: 'font-size:12px; margin-bottom:8px' },
'Declare the entitlements this product offers (e.g. "core", "ai_summaries"). ' +
'Policies will pick from this list — buyers see the display name + description, ' +
'never the raw slug. Leave empty to use the legacy free-text mode (any string allowed).'),
rowsHost,
addBtn,
])
return {
element: wrap,
read: function () {
const out = []
rowsHost.querySelectorAll('.catalog-row').forEach((row) => {
const slug = row.querySelector('[data-field=slug]').value.trim()
if (!slug) return
const name = row.querySelector('[data-field=name]').value.trim()
const description = row.querySelector('[data-field=description]').value.trim()
out.push({ slug, name: name || slug, description })
})
return out.length > 0 ? out : null
},
}
}
/**
* Bubble multi-select for picking entitlements off a product's
* catalog. Renders one clickable pill per catalog entry; click to
* toggle selected state. Hover shows the description.
*
* Used in the policy create + edit forms when the parent product
* has a non-empty catalog (closed-list mode). When the catalog is
* empty, callers fall back to the legacy free-text textarea.
*
* const picker = entitlementBubblePicker(catalog, ['core', 'pro'])
* container.appendChild(picker.element)
* const slugs = picker.read() // -> ['core', 'pro']
*/
function entitlementBubblePicker(catalog, initialSelection) {
const selected = new Set(Array.isArray(initialSelection) ? initialSelection : [])
const host = el('div', {
style: 'display:flex; flex-wrap:wrap; gap:6px; margin-top:4px',
})
const pills = []
function renderPill(entry) {
const isSel = selected.has(entry.slug)
const pill = el('button', {
type: 'button',
title: entry.description || entry.slug,
'data-slug': entry.slug,
style:
'padding:6px 12px; border-radius:999px; cursor:pointer; ' +
'font-family:var(--font-body); font-size:13px; font-weight:500; ' +
'transition:all 100ms; ' +
(isSel
? 'background:var(--gold-500); color:var(--navy-950); border:1px solid var(--gold-500); '
: 'background:transparent; color:var(--ink-700); border:1px solid var(--border-2); '),
}, entry.name || entry.slug)
pill.addEventListener('click', () => {
if (selected.has(entry.slug)) {
selected.delete(entry.slug)
} else {
selected.add(entry.slug)
}
// Re-style only this pill rather than re-rendering the host.
const nowSel = selected.has(entry.slug)
pill.style.background = nowSel ? 'var(--gold-500)' : 'transparent'
pill.style.color = nowSel ? 'var(--navy-950)' : 'var(--ink-700)'
pill.style.borderColor = nowSel ? 'var(--gold-500)' : 'var(--border-2)'
})
pills.push(pill)
host.appendChild(pill)
}
;(catalog || []).forEach(renderPill)
const wrap = el('div', { class: 'field' }, [
el('label', { class: 'lbl' }, 'Entitlements'),
el('div', { class: 'muted', style: 'font-size:12px; margin-bottom:6px' },
'Click each entitlement this tier should grant. Defined on the parent product\'s catalog.'),
host,
])
return {
element: wrap,
read: () => Array.from(selected),
}
}
// ---------- nav + auth ---------- // ---------- nav + auth ----------
function setRoute(name) { function setRoute(name) {
const links = document.querySelectorAll('.sidebar a.nav') const links = document.querySelectorAll('.sidebar a.nav')