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