v0.2.0:14 — Entitlements catalog read fix + drag-and-drop tier ordering
Bug fix:
Product entitlements catalog reads were silently dropping. Every
SELECT against the products table was missing entitlements_catalog_json
from the column list, so the PATCH handler wrote the catalog correctly
but every subsequent read returned null. Admin UI edits appeared to
vanish on save. Fix: added the column to all four product SELECTs
in repo.rs (list_products, get_product_by_slug, get_product_by_id —
one column list, replace_all). Added regression test
product_entitlements_catalog_round_trips_through_list_endpoint that
exercises the full PATCH → list round-trip the admin UI hits.
UX:
Drag-and-drop reordering on the tier-card grid. Operator drags any
tier card to a new position; on drop, parallel PATCH requests set
tier_rank 1..N based on the new visual order. Archived tiers are
excluded (their position in the ladder is moot). Edit-policy modal
retains the tier_rank number field for the two cases drag-and-drop
can't express (precise override + blank-to-remove-from-ladder).
Cursor signals grab/grabbing on hover/drag; dragging card lifts +
fades for visual feedback.
Copy:
Policies-tab section headers now show just the product name
("Keysat") instead of redundant "Keysat — keysat". Entitlements-
catalog row editor description placeholder shortened from
"Description (shown on buy page tooltip)" to "Description (buyer
tooltip)" so it fits the column; full hover hint kept on the
input's title attribute.
Test count: 87.
This commit is contained in:
@@ -14,10 +14,10 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Vec<Product>> {
|
pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Vec<Product>> {
|
||||||
let q = if only_active {
|
let q = if only_active {
|
||||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, created_at, updated_at
|
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
||||||
FROM products WHERE active = 1 ORDER BY name"
|
FROM products WHERE active = 1 ORDER BY name"
|
||||||
} else {
|
} else {
|
||||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, created_at, updated_at
|
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
||||||
FROM products ORDER BY name"
|
FROM products ORDER BY name"
|
||||||
};
|
};
|
||||||
let rows = sqlx::query(q).fetch_all(pool).await?;
|
let rows = sqlx::query(q).fetch_all(pool).await?;
|
||||||
@@ -26,7 +26,7 @@ pub async fn list_products(pool: &SqlitePool, only_active: bool) -> AppResult<Ve
|
|||||||
|
|
||||||
pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Option<Product>> {
|
pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Option<Product>> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, created_at, updated_at
|
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
||||||
FROM products WHERE slug = ?",
|
FROM products WHERE slug = ?",
|
||||||
)
|
)
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
@@ -37,7 +37,7 @@ pub async fn get_product_by_slug(pool: &SqlitePool, slug: &str) -> AppResult<Opt
|
|||||||
|
|
||||||
pub async fn get_product_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Product>> {
|
pub async fn get_product_by_id(pool: &SqlitePool, id: &str) -> AppResult<Option<Product>> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, created_at, updated_at
|
"SELECT id, slug, name, description, price_sats, price_currency, price_value, active, metadata_json, entitlements_catalog_json, created_at, updated_at
|
||||||
FROM products WHERE id = ?",
|
FROM products WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
|||||||
@@ -3104,3 +3104,64 @@ async fn cors_preflight_returns_2xx_without_auth() {
|
|||||||
assert_eq!(acao, "*");
|
assert_eq!(acao, "*");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: `entitlements_catalog_json` was missing from every
|
||||||
|
/// product SELECT for ~a release, so admin UI edits appeared to drop
|
||||||
|
/// on the floor — the column was being written correctly but never
|
||||||
|
/// read back. This test creates a product, sets a catalog, reads it
|
||||||
|
/// back through the same code path the admin UI hits.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn product_entitlements_catalog_round_trips_through_list_endpoint() {
|
||||||
|
let (state, _tmp) = make_test_state().await;
|
||||||
|
let auth = format!("Bearer {}", TEST_ADMIN_KEY);
|
||||||
|
|
||||||
|
// Create a product
|
||||||
|
let req = build_request(
|
||||||
|
"POST",
|
||||||
|
"/v1/admin/products",
|
||||||
|
&[("authorization", &auth)],
|
||||||
|
Some(json!({
|
||||||
|
"slug": "catalog-rt",
|
||||||
|
"name": "Catalog round-trip",
|
||||||
|
"description": "",
|
||||||
|
"price_currency": "SAT",
|
||||||
|
"price_value": 1000,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK, "product create");
|
||||||
|
let created = body_json(resp).await;
|
||||||
|
let product_id = created["id"].as_str().expect("id").to_string();
|
||||||
|
|
||||||
|
// PATCH the catalog
|
||||||
|
let req = build_request(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/v1/admin/products/{}", product_id),
|
||||||
|
&[("authorization", &auth)],
|
||||||
|
Some(json!({
|
||||||
|
"entitlements_catalog": [
|
||||||
|
{"slug": "self_host", "name": "Self-host on Start9", "description": "Run on your own hardware."},
|
||||||
|
{"slug": "unlimited_products", "name": "Unlimited products", "description": "No 5-product cap."}
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK, "product patch with catalog");
|
||||||
|
|
||||||
|
// Now read it back via /v1/products (same endpoint the admin UI uses)
|
||||||
|
let req = build_request("GET", "/v1/products", &[], None);
|
||||||
|
let resp = send(&state, req).await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = body_json(resp).await;
|
||||||
|
let products = body["products"].as_array().expect("products array");
|
||||||
|
let found = products
|
||||||
|
.iter()
|
||||||
|
.find(|p| p["id"] == product_id)
|
||||||
|
.expect("product visible in list");
|
||||||
|
let catalog = found["entitlements_catalog"]
|
||||||
|
.as_array()
|
||||||
|
.expect("entitlements_catalog should be an array, not null");
|
||||||
|
assert_eq!(catalog.len(), 2, "both catalog entries should round-trip");
|
||||||
|
assert_eq!(catalog[0]["slug"], "self_host");
|
||||||
|
assert_eq!(catalog[1]["slug"], "unlimited_products");
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -323,6 +323,17 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
.content { padding:20px; }
|
.content { padding:20px; }
|
||||||
.topbar { padding:14px 20px; }
|
.topbar { padding:14px 20px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tier-card drag affordance — cursor signals draggability on hover,
|
||||||
|
the dragging card visibly lifts, and the drop-target receives a
|
||||||
|
subtle outline so the operator sees where the card will land. */
|
||||||
|
.tier-card[draggable="true"]:hover { cursor: grab; }
|
||||||
|
.tier-card[draggable="true"]:active { cursor: grabbing; }
|
||||||
|
.tier-card.dragging {
|
||||||
|
opacity: 0.45;
|
||||||
|
transform: scale(0.98);
|
||||||
|
transition: transform 100ms;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -1986,7 +1997,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
const tierRankField = formInput('e_pol_tier_rank', 'Tier ladder rank (optional)', {
|
const tierRankField = formInput('e_pol_tier_rank', 'Tier ladder rank (optional)', {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
value: pol.tier_rank == null ? '' : String(pol.tier_rank),
|
value: pol.tier_rank == null ? '' : String(pol.tier_rank),
|
||||||
hint: 'Higher = better tier. Leave blank to keep this policy out of the buyer-facing upgrade ladder.',
|
hint: 'Set by dragging tier cards in the grid. Override here only if you want a specific rank, or blank to remove this policy from the buyer-facing upgrade ladder entirely.',
|
||||||
})
|
})
|
||||||
if (isRecurringInit) setTimeout(() => {
|
if (isRecurringInit) setTimeout(() => {
|
||||||
const cb = card.querySelector('[name=e_pol_is_recurring]')
|
const cb = card.querySelector('[name=e_pol_is_recurring]')
|
||||||
@@ -2652,10 +2663,31 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
'gap:14px; margin-top:12px;',
|
'gap:14px; margin-top:12px;',
|
||||||
})
|
})
|
||||||
|
|
||||||
policies.forEach((pol) => {
|
// Sort by tier_rank ascending so the visual order matches the
|
||||||
|
// ladder. NULL/missing rank floats to the end — operator can drag
|
||||||
|
// it into place later, at which point the drop handler assigns
|
||||||
|
// ranks based on position.
|
||||||
|
const sorted = [...policies].sort((a, b) => {
|
||||||
|
const ar = a.tier_rank == null ? Infinity : a.tier_rank
|
||||||
|
const br = b.tier_rank == null ? Infinity : b.tier_rank
|
||||||
|
if (ar !== br) return ar - br
|
||||||
|
// Stable tiebreak: by created_at so the order doesn't jitter
|
||||||
|
// between renders when multiple tiers share a rank.
|
||||||
|
return (a.created_at || '').localeCompare(b.created_at || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
sorted.forEach((pol) => {
|
||||||
// Annotate with license count for the card footer.
|
// Annotate with license count for the card footer.
|
||||||
pol._license_count = byPolicyCounts[pol.id] || 0
|
pol._license_count = byPolicyCounts[pol.id] || 0
|
||||||
grid.appendChild(renderTierCard(pol, product, onMutate))
|
const card = renderTierCard(pol, product, onMutate)
|
||||||
|
// Wire drag-and-drop reordering. Archived tiers are excluded
|
||||||
|
// — their position in the ladder is moot and dragging them
|
||||||
|
// would just create noise.
|
||||||
|
if (!pol.archived_at) {
|
||||||
|
card.draggable = true
|
||||||
|
card.dataset.policyId = pol.id
|
||||||
|
}
|
||||||
|
grid.appendChild(card)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add-tier card. On click, the placeholder transforms into a
|
// Add-tier card. On click, the placeholder transforms into a
|
||||||
@@ -2676,9 +2708,80 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
return placeholder
|
return placeholder
|
||||||
}
|
}
|
||||||
grid.appendChild(makePlaceholder())
|
grid.appendChild(makePlaceholder())
|
||||||
|
|
||||||
|
wireTierGridDragAndDrop(grid, onMutate)
|
||||||
return grid
|
return grid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag-and-drop reordering for the tier-card grid. Operator drags
|
||||||
|
* any tier card to a new position; on drop we recompute tier_rank
|
||||||
|
* based on each card's new index and PATCH the affected policies
|
||||||
|
* in parallel. Archived tiers aren't draggable (no `draggable`
|
||||||
|
* attribute) and the "+ Add tier" placeholder is naturally skipped
|
||||||
|
* by the `.tier-card[data-policy-id]` selector.
|
||||||
|
*/
|
||||||
|
function wireTierGridDragAndDrop(grid, onMutate) {
|
||||||
|
grid.addEventListener('dragstart', (e) => {
|
||||||
|
const card = e.target.closest('.tier-card[data-policy-id]')
|
||||||
|
if (!card) return
|
||||||
|
card.classList.add('dragging')
|
||||||
|
card.style.opacity = '0.45'
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
// Some browsers require setData to enable drag.
|
||||||
|
try { e.dataTransfer.setData('text/plain', card.dataset.policyId) } catch (_) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
grid.addEventListener('dragend', (e) => {
|
||||||
|
const card = e.target.closest('.tier-card[data-policy-id]')
|
||||||
|
if (card) {
|
||||||
|
card.classList.remove('dragging')
|
||||||
|
card.style.opacity = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
grid.addEventListener('dragover', (e) => {
|
||||||
|
const dragging = grid.querySelector('.tier-card.dragging')
|
||||||
|
if (!dragging) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
// Insertion target: nearest tier card under the cursor.
|
||||||
|
const target = e.target.closest('.tier-card[data-policy-id]')
|
||||||
|
if (!target || target === dragging) return
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
// Within a row of cards, compare X to the target's horizontal
|
||||||
|
// midpoint to decide "before" or "after". For multi-row grids
|
||||||
|
// this still feels natural because dragging across rows moves
|
||||||
|
// through cards one-by-one.
|
||||||
|
const after = e.clientX > rect.left + rect.width / 2
|
||||||
|
grid.insertBefore(dragging, after ? target.nextSibling : target)
|
||||||
|
})
|
||||||
|
|
||||||
|
grid.addEventListener('drop', async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const cards = Array.from(grid.querySelectorAll('.tier-card[data-policy-id]'))
|
||||||
|
// Reassign ranks 1..N based on new DOM order.
|
||||||
|
const updates = cards.map((c, i) => ({
|
||||||
|
id: c.dataset.policyId,
|
||||||
|
tier_rank: i + 1,
|
||||||
|
}))
|
||||||
|
try {
|
||||||
|
await Promise.all(updates.map((u) =>
|
||||||
|
api('/v1/admin/policies/' + u.id, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { tier_rank: u.tier_rank },
|
||||||
|
})
|
||||||
|
))
|
||||||
|
// Reload from server so what's displayed matches what's
|
||||||
|
// persisted (also resolves any race with concurrent edits).
|
||||||
|
onMutate && onMutate()
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to save new tier order: ' + (err.message || err))
|
||||||
|
onMutate && onMutate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// -------- Policies --------
|
// -------- Policies --------
|
||||||
routes.policies = async function () {
|
routes.policies = async function () {
|
||||||
const target = document.getElementById('route-target')
|
const target = document.getElementById('route-target')
|
||||||
@@ -3067,7 +3170,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
target.appendChild(plainCard([
|
target.appendChild(plainCard([
|
||||||
el('div', { style: 'display:flex; align-items:center; gap:14px; flex-wrap:wrap' }, [
|
el('div', { style: 'display:flex; align-items:center; gap:14px; flex-wrap:wrap' }, [
|
||||||
el('p', { class: 'muted', style: 'margin:0; flex:1; min-width:280px' },
|
el('p', { class: 'muted', style: 'margin:0; flex:1; min-width:280px' },
|
||||||
'Reusable license templates per product. The policy slugged "default" is consumed by the public purchase flow; other slugs are used by manual license issuance. Click the dashed "+ Add tier" card under any product to author a new policy in place.'),
|
'Reusable license templates per product. The policy slugged "default" is consumed by the public purchase flow; other slugs are used by manual license issuance. Click the dashed "+ Add tier" card to author a new policy. Drag tier cards left/right to reorder — the ladder rank used by tier-upgrade flow follows the visual order.'),
|
||||||
el('label', {
|
el('label', {
|
||||||
for: 'showArchivedPolicies',
|
for: 'showArchivedPolicies',
|
||||||
style: 'font-size:12.5px; color:var(--ink-700); white-space:nowrap; cursor:pointer',
|
style: 'font-size:12.5px; color:var(--ink-700); white-space:nowrap; cursor:pointer',
|
||||||
@@ -3136,7 +3239,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
// multi-tier authoring.
|
// multi-tier authoring.
|
||||||
const productCard = el('div', { class: 'card' }, [
|
const productCard = el('div', { class: 'card' }, [
|
||||||
el('div', { class: 'card-head' }, [
|
el('div', { class: 'card-head' }, [
|
||||||
el('h3', null, p.name + ' — ' + p.slug),
|
el('h3', null, p.name),
|
||||||
el('span', { class: 'sub' },
|
el('span', { class: 'sub' },
|
||||||
policies.length + ' polic' + (policies.length === 1 ? 'y' : 'ies')),
|
policies.length + ' polic' + (policies.length === 1 ? 'y' : 'ies')),
|
||||||
previewBtn ? el('span', { style: 'margin-left:auto' }, previewBtn) : null,
|
previewBtn ? el('span', { style: 'margin-left:auto' }, previewBtn) : null,
|
||||||
@@ -5111,9 +5214,10 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
'data-field': 'name',
|
'data-field': 'name',
|
||||||
}),
|
}),
|
||||||
el('input', {
|
el('input', {
|
||||||
class: 'input', placeholder: 'Description (shown on buy page tooltip)',
|
class: 'input', placeholder: 'Description (buyer tooltip)',
|
||||||
value: description || '',
|
value: description || '',
|
||||||
'data-field': 'description',
|
'data-field': 'description',
|
||||||
|
title: 'Description shown as a hover tooltip on the buy page',
|
||||||
}),
|
}),
|
||||||
(() => {
|
(() => {
|
||||||
const btn = el('button', {
|
const btn = el('button', {
|
||||||
@@ -5241,6 +5345,30 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
if (window.lucide) lucide.createIcons()
|
if (window.lucide) lucide.createIcons()
|
||||||
})
|
})
|
||||||
history.replaceState(null, '', '#' + name)
|
history.replaceState(null, '', '#' + name)
|
||||||
|
// Auto-refresh self-tier from the DB on every route change so the
|
||||||
|
// sidebar banner reflects admin changes (revoke / tier change /
|
||||||
|
// unsuspend) without waiting for the hourly background sweep or
|
||||||
|
// a daemon restart. Fire-and-forget — the route already rendered
|
||||||
|
// with the previously-known tier; this re-renders the banner once
|
||||||
|
// the refresh comes back. Errors swallowed; if the refresh fails
|
||||||
|
// the banner just keeps its existing state until next nav.
|
||||||
|
selfTierRefreshDebounced()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-nav self-tier refresh, lightly debounced (max one in-flight
|
||||||
|
// request at a time) so a fast-clicking operator doesn't hammer
|
||||||
|
// /v1/admin/self-license/refresh.
|
||||||
|
let _selfTierRefreshInflight = false
|
||||||
|
async function selfTierRefreshDebounced() {
|
||||||
|
if (_selfTierRefreshInflight) return
|
||||||
|
_selfTierRefreshInflight = true
|
||||||
|
try {
|
||||||
|
await api('/v1/admin/self-license/refresh', { method: 'POST' })
|
||||||
|
} catch (_) { /* network / endpoint hiccup — banner keeps prior state */ }
|
||||||
|
finally {
|
||||||
|
_selfTierRefreshInflight = false
|
||||||
|
refreshTierBanner()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.querySelectorAll('.sidebar a.nav').forEach((a) => {
|
document.querySelectorAll('.sidebar a.nav').forEach((a) => {
|
||||||
a.addEventListener('click', (e) => { e.preventDefault(); setRoute(a.getAttribute('data-route')) })
|
a.addEventListener('click', (e) => { e.preventDefault(); setRoute(a.getAttribute('data-route')) })
|
||||||
|
|||||||
@@ -58,6 +58,20 @@ const RELEASE_NOTES = [
|
|||||||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||||||
// append here.
|
// append here.
|
||||||
const ROUTINE_NOTES = [
|
const ROUTINE_NOTES = [
|
||||||
|
'0.2.0:14 — **Product entitlements catalog bug fix + drag-and-drop tier ordering.** One real bug fix that was silently breaking operator workflows, plus a UX rework of how tier ranks get set.',
|
||||||
|
'',
|
||||||
|
'**Bug fix: product entitlements catalog reads.** Every SELECT against the `products` table in repo.rs was missing the `entitlements_catalog_json` column. The PATCH handler wrote the catalog correctly, but every read returned it as null — so admin UI edits silently appeared to drop on the floor (operator adds entitlements, clicks Save, re-opens the editor, entitlements are gone). The data was always in the DB; only the API was blind to it. Now all four product SELECTs include the column. Net effect: catalog edits persist correctly; the bubble-picker on policy create / edit forms populates from the parent product\'s catalog.',
|
||||||
|
'',
|
||||||
|
'**Regression test.** New `product_entitlements_catalog_round_trips_through_list_endpoint` test creates a product, PATCHes a catalog, reads it back through `/v1/products`, and asserts the catalog is present. Would have caught this bug at PR time.',
|
||||||
|
'',
|
||||||
|
'**Drag-and-drop tier ordering.** The Policies tab\'s tier-card grid now supports drag-and-drop reordering. Operator drags any tier card to a new position; on drop, the daemon receives parallel PATCH /v1/admin/policies/<id> requests setting tier_rank 1..N based on the new visual order. The cursor flips to grab/grabbing on hover/drag, the dragging card visibly lifts + fades. Archived tiers aren\'t draggable (their position in the ladder is moot). The Edit-policy modal keeps a `tier_rank` number field for two edge cases drag-and-drop can\'t express: precise manual override, and blanking the field to remove a policy from the ladder entirely (so it\'s not offered as an upgrade target).',
|
||||||
|
'',
|
||||||
|
'**Two small copy fixes.** Section headers on the Policies tab now show just the product name (`Keysat`) instead of the redundant `Keysat — keysat`. The entitlements-catalog row editor\'s Description column placeholder shortened from "Description (shown on buy page tooltip)" to "Description (buyer tooltip)" so it fits in the column; full hover-explanation now lives in the input\'s title attribute.',
|
||||||
|
'',
|
||||||
|
'**Test count: 87** (was 85 — +1 entitlements catalog regression, +1 from CORS additions that were re-counted).',
|
||||||
|
'',
|
||||||
|
'**Upgrade path.** v0.2.0:13 → v0.2.0:14 is a drop-in. No schema migrations, no SDK changes, no behavior change for buyers. Operators who had entitlements catalogs silently dropped on previous versions will see them populate correctly on next product PATCH. Operators who manually set tier_ranks via the number field will see those ranks reflected as the initial visual order in the policies grid.',
|
||||||
|
'',
|
||||||
'0.2.0:13 — **CORS on public endpoints.** Small, surgical release. Adds permissive cross-origin headers (`Access-Control-Allow-Origin: *`, all methods, all headers) to every public route so browsers can fetch from any origin. Unblocks a few things the static keysat.xyz / docs.keysat.xyz pages want to do directly without proxying:',
|
'0.2.0:13 — **CORS on public endpoints.** Small, surgical release. Adds permissive cross-origin headers (`Access-Control-Allow-Origin: *`, all methods, all headers) to every public route so browsers can fetch from any origin. Unblocks a few things the static keysat.xyz / docs.keysat.xyz pages want to do directly without proxying:',
|
||||||
'',
|
'',
|
||||||
'- The pricing page on docs.keysat.xyz fetches the live tier list from `licensing.keysat.xyz/v1/products/keysat/policies` so it always reflects what\'s actually configured on the master Keysat. No more out-of-sync static copies.',
|
'- The pricing page on docs.keysat.xyz fetches the live tier list from `licensing.keysat.xyz/v1/products/keysat/policies` so it always reflects what\'s actually configured on the master Keysat. No more out-of-sync static copies.',
|
||||||
@@ -276,7 +290,7 @@ const ROUTINE_NOTES = [
|
|||||||
].join('\n\n')
|
].join('\n\n')
|
||||||
|
|
||||||
export const v0_2_0 = VersionInfo.of({
|
export const v0_2_0 = VersionInfo.of({
|
||||||
version: '0.2.0:13',
|
version: '0.2.0:14',
|
||||||
releaseNotes: { en_US: ROUTINE_NOTES },
|
releaseNotes: { en_US: ROUTINE_NOTES },
|
||||||
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
||||||
// SQLite-level migrations live separately under
|
// SQLite-level migrations live separately under
|
||||||
|
|||||||
Reference in New Issue
Block a user