From be7c59aa0fd4445fee10f04a4d4ef9bbace6098c Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:46:12 +0200 Subject: [PATCH] fix(products): source products and entitlements from the registry, not hard-coded ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A started trial never showed as entitled. Two causes, both fixed here. 1. The live path never fetched entitlements at all. loadTenantForShell resolves fixture tenants first and, for a REAL tenant, returned a shim with `entitled: []` and `products: []` hard-coded — so `entitled.includes(p.id)` was false for everything no matter what the registry said. It now fetches /v1/catalog and /v1/entitlements (both best-effort: a failure degrades to an empty grid rather than 404-ing the whole page) and filters entitlements to enabled + not expired. 2. The ids could not match even in principle. The registry catalog offers `certifai` and `compliance`; the portal's hard-coded list used `compliance-scanner` — a different product entirely (it lives in ~/workspace/compliance-scanner and is not in this catalog). The findings filter hard-coded the same wrong id. The fix removes the class of bug rather than renaming one constant: products are now derived from the registry catalog, and `entitled` is built from the SAME registry keys, so the two sides cannot drift apart. The findings filter is likewise derived from the tenant's own products. Verified against the live dev registry: catalog returns [certifai, compliance]; after starting a trial for acme, entitlements returns `compliance` (enabled, expires 2026-09-16), and the mapping yields entitled=[compliance] with entitled.includes("compliance")=true and certifai=false. tsc --noEmit 0, next build 0. Fixture tenants are unaffected — they still carry their own products, and the derived filter uses those. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CNdLL9BdsWm7MCyui5ffPD --- src/app/[slug]/products/page.tsx | 19 ++++++--- src/lib/portal-data.ts | 70 +++++++++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/src/app/[slug]/products/page.tsx b/src/app/[slug]/products/page.tsx index 634119d..9bfda7c 100644 --- a/src/app/[slug]/products/page.tsx +++ b/src/app/[slug]/products/page.tsx @@ -107,13 +107,22 @@ export default async function ProductsPage({ title="Findings across products" tail={ - {["all", "compliance-scanner", "certifai"].map((o) => ( + {/* Derived from the tenant's own products, not a hard-coded list — + a hard-coded id (e.g. "compliance-scanner") silently filters to + nothing when the registry calls the product something else. */} + + All + + {t.products.map((p) => ( - {o === "all" ? "All" : o === "certifai" ? "CERTifAI" : "Scanner"} + {p.name} ))} diff --git a/src/lib/portal-data.ts b/src/lib/portal-data.ts index 96c17b3..978fbb5 100644 --- a/src/lib/portal-data.ts +++ b/src/lib/portal-data.ts @@ -10,11 +10,51 @@ // Once the registry is enriched to carry the design fields end-to-end this // module collapses into a thin pass-through. -import { tenantBySlug, type TenantRecord } from "@/lib/fixtures"; -import { fetchTenantBySlug, type Tenant } from "@/lib/tenant-registry"; +import { tenantBySlug, type ProductDef, type TenantRecord } from "@/lib/fixtures"; +import { + fetchCatalog, + fetchEntitlements, + fetchTenantBySlug, + type CatalogEntry, + type Entitlement, + type Tenant, +} from "@/lib/tenant-registry"; export type PortalTenant = TenantRecord; +/** Registry catalog entry -> the shape the product screens render. + * + * The registry `key` becomes both `id` and `slug`, deliberately. The screens + * compare `entitled.includes(p.id)`, and `entitled` is built from the SAME + * registry keys below — so the two sides cannot drift apart. Hard-coding a + * product list here is what previously made a real entitlement (`compliance`) + * fail to match a hand-written product id (`compliance-scanner`). + * + * `frameworks` is empty because the registry contract does not carry it + * (PRODUCT_INTEGRATION_SPEC has products publish a manifest later); the UI + * already renders an empty list without complaint. + */ +function productFromCatalog(entry: CatalogEntry): ProductDef { + return { + id: entry.key, + slug: entry.key, + name: entry.name, + mono: entry.name.replace(/[^A-Za-z]/g, "").slice(0, 2).toUpperCase() || "??", + status: "live", + blurb: entry.description, + frameworks: [], + }; +} + +/** Entitlements the tenant may actually use right now. */ +function activeEntitlements(items: Entitlement[], nowMs: number): Entitlement[] { + return items.filter((e) => { + if (!e.enabled) return false; + if (!e.expires_at) return true; + return new Date(e.expires_at).getTime() > nowMs; + }); +} + export async function loadTenantForShell(slug: string): Promise { const fx = tenantBySlug(slug); if (fx) return fx; @@ -30,6 +70,26 @@ export async function loadTenantForShell(slug: string): Promise [] as CatalogEntry[]), + fetchEntitlements(live.id).catch(() => [] as Entitlement[]), + ]); + const active = activeEntitlements(entitlements, now); + const entitled = active.map((e) => e.product); + const trialing = active + .filter((e) => Boolean(e.expires_at)) + .map((e) => e.product); + // Show the catalog, with the entitled ones first so the grid leads with what + // the tenant can actually open. + const products = catalog + .map(productFromCatalog) + .sort((a, b) => Number(entitled.includes(b.id)) - Number(entitled.includes(a.id))); + // Minimal shim so the shell can render. Design-rich fields fall back to // placeholders that won't blow up the layout. return { @@ -51,12 +111,12 @@ export async function loadTenantForShell(slug: string): Promise