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:
@@ -93,6 +93,13 @@ pub struct CreateProductReq {
|
||||
pub price_value: Option<i64>,
|
||||
#[serde(default)]
|
||||
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
|
||||
@@ -189,6 +196,15 @@ pub async fn create_product(
|
||||
&metadata,
|
||||
)
|
||||
.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(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
@@ -406,6 +422,27 @@ pub struct UpdateProductReq {
|
||||
pub price_currency: Option<String>,
|
||||
#[serde(default)]
|
||||
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(
|
||||
@@ -477,6 +514,18 @@ pub async fn update_product(
|
||||
pricing_patch.as_ref().map(|(c, v)| (c.as_str(), *v)),
|
||||
)
|
||||
.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(
|
||||
&state.db,
|
||||
"admin_api_key",
|
||||
|
||||
@@ -979,13 +979,34 @@ fn render_tier_picker(
|
||||
.as_ref()
|
||||
.map(|ip| ip.slug == p.slug)
|
||||
.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() {
|
||||
String::new()
|
||||
} else {
|
||||
let catalog = product.entitlements_catalog.as_deref().unwrap_or(&[]);
|
||||
let lis: Vec<String> = p
|
||||
.entitlements
|
||||
.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();
|
||||
format!("<ul class=\"tier-entitlements\">{}</ul>", lis.join(""))
|
||||
};
|
||||
|
||||
@@ -123,6 +123,36 @@ fn validate_recurring(
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
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(
|
||||
&state.db,
|
||||
&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 {
|
||||
is_recurring: req.is_recurring,
|
||||
renewal_period_days: req.renewal_period_days,
|
||||
@@ -671,12 +721,23 @@ pub async fn list_public_policies(
|
||||
})
|
||||
.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!({
|
||||
"product": {
|
||||
"slug": product.slug,
|
||||
"name": product.name,
|
||||
"description": product.description,
|
||||
"base_price_sats": product.price_sats,
|
||||
"entitlements_catalog": entitlements_catalog,
|
||||
},
|
||||
"policies": policies_json,
|
||||
})))
|
||||
|
||||
@@ -243,6 +243,64 @@ pub async fn update_product_with_currency(
|
||||
.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> {
|
||||
let metadata_json: String = row.try_get("metadata_json")?;
|
||||
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
|
||||
.try_get("price_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 {
|
||||
id: row.try_get("id")?,
|
||||
slug: row.try_get("slug")?,
|
||||
@@ -268,6 +336,7 @@ fn row_to_product(row: sqlx::sqlite::SqliteRow) -> AppResult<Product> {
|
||||
price_value,
|
||||
active: active_int != 0,
|
||||
metadata,
|
||||
entitlements_catalog,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
})
|
||||
|
||||
@@ -27,10 +27,34 @@ pub struct Product {
|
||||
pub active: bool,
|
||||
/// Arbitrary JSON metadata the developer can attach.
|
||||
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 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 {
|
||||
"SAT".to_string()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user