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
+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 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
// 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('div', { style: 'display:flex; gap:8px; align-items:flex-start' }, [priceInput, curPicker]),
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,
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
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_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 })
overlay.remove()
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.'
}
})
const createCatalog = catalogEditor(null)
const create = el('details', { class: 'disclosure' }, [
el('summary', null, 'Create a new product'),
el('div', { class: 'body' }, [
@@ -1227,6 +1241,11 @@ The request will be refused if there are licenses or invoices tied to it — use
currencyPicker,
]),
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
? 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
// entered as decimal amounts and converted to cents.
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(),
name: create.querySelector('[name=name]').value.trim(),
description: create.querySelector('[name=description]').value || '',
price_currency: currency,
price_value: priceValue,
metadata: {},
}})
}
if (catalog) body.entitlements_catalog = catalog
await api('/v1/admin/products', { method: 'POST', body })
status.replaceWith(ok('Created. Reloading…'))
setTimeout(routes.products, 600)
} 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)', {
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)', {
textarea: true,
value: (pol.entitlements || []).join('\n'),
hint: 'Examples: self_host, ai_summaries, export. No quotes or brackets.',
})
// Entitlements input: bubble picker against the product's catalog
// (closed-list mode) when one exists, else legacy free-text
// textarea. The picker pre-selects the policy's current
// 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')
if (highlight) setTimeout(() => {
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 grace_days = parseInt(card.querySelector('[name=e_pol_grace]').value, 10) || 0
const grace_seconds = grace_days * 86400
const rawEnts = card.querySelector('[name=e_pol_entitlements]').value || ''
const ents = Array.from(new Set(
rawEnts.replace(/[\[\]"'`]/g, '').split(/[\n,]/).map((s) => s.trim()).filter(Boolean)
))
// Read from whichever mode the entitlements host is in
// (bubble picker vs textarea fallback). _read is set by
// 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 newHighlight = card.querySelector('[name=e_pol_highlight]').checked
// 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
// when the operator picks a product from the dropdown.
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 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.
formInput('entitlements', 'Entitlements (one per line, or comma-separated)', {
textarea: true,
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.',
}),
// Entitlements input — swaps based on product's catalog:
// - Closed list (catalog has entries): bubble multi-select
// - Legacy / no catalog: free-text textarea
// 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' }, [
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…')
create.querySelector('.body').appendChild(status)
try {
// Entitlements: split on newlines OR commas, trim, dedupe, drop empties.
// Also strip any quotes/brackets a paranoid operator might have typed.
const rawEnts = create.querySelector('[name=entitlements]').value || ''
const ents = Array.from(new Set(
rawEnts
.replace(/[\[\]"'`]/g, '') // strip JSON noise if pasted
.split(/[\n,]/)
.map((s) => s.trim())
.filter(Boolean)
))
// Entitlements: read either from the bubble picker
// (when the product has a catalog) or the legacy
// free-text textarea. _read is set on the host by
// entitlementBubblePicker; absence = textarea mode.
const entHost = create.querySelector('[data-ent-host]')
let ents = []
if (entHost && entHost._mode === 'bubbles' && entHost._read) {
ents = entHost._read()
} 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.
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)
}
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([
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,
].filter(Boolean))
: 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, activePill(pol.active)),
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 ----------
function setRoute(name) {
const links = document.querySelectorAll('.sidebar a.nav')