fix(products): source products and entitlements from the registry, not hard-coded ids
ci / test (pull_request) Successful in 10m23s
ci / e2e (pull_request) Blocked by required conditions
ci / shared (pull_request) Failing after 13s
ci / image (pull_request) Skipped

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNdLL9BdsWm7MCyui5ffPD
This commit is contained in:
Sharang Parnerkar
2026-09-02 10:46:12 +02:00
co-authored by Claude Fable 5
parent ac28e2256f
commit be7c59aa0f
2 changed files with 79 additions and 10 deletions
+14 -5
View File
@@ -107,13 +107,22 @@ export default async function ProductsPage({
title="Findings across products" title="Findings across products"
tail={ tail={
<span className="row" style={{ gap: 6 }}> <span className="row" style={{ gap: 6 }}>
{["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. */}
<Link <Link
key={o} href={`/${slug}/products`}
href={o === "all" ? `/${slug}/products` : `/${slug}/products?p=${o}`} className={"btn btn-sm" + (productFilter === "all" ? " btn-primary" : "")}
className={"btn btn-sm" + (productFilter === o ? " btn-primary" : "")}
> >
{o === "all" ? "All" : o === "certifai" ? "CERTifAI" : "Scanner"} All
</Link>
{t.products.map((p) => (
<Link
key={p.id}
href={`/${slug}/products?p=${p.slug}`}
className={"btn btn-sm" + (productFilter === p.slug ? " btn-primary" : "")}
>
{p.name}
</Link> </Link>
))} ))}
</span> </span>
+65 -5
View File
@@ -10,11 +10,51 @@
// Once the registry is enriched to carry the design fields end-to-end this // Once the registry is enriched to carry the design fields end-to-end this
// module collapses into a thin pass-through. // module collapses into a thin pass-through.
import { tenantBySlug, type TenantRecord } from "@/lib/fixtures"; import { tenantBySlug, type ProductDef, type TenantRecord } from "@/lib/fixtures";
import { fetchTenantBySlug, type Tenant } from "@/lib/tenant-registry"; import {
fetchCatalog,
fetchEntitlements,
fetchTenantBySlug,
type CatalogEntry,
type Entitlement,
type Tenant,
} from "@/lib/tenant-registry";
export type PortalTenant = TenantRecord; 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<PortalTenant | null> { export async function loadTenantForShell(slug: string): Promise<PortalTenant | null> {
const fx = tenantBySlug(slug); const fx = tenantBySlug(slug);
if (fx) return fx; if (fx) return fx;
@@ -30,6 +70,26 @@ export async function loadTenantForShell(slug: string): Promise<PortalTenant | n
} }
if (!live) return null; if (!live) return null;
// Entitlements and catalog are what make a started trial actually show as
// entitled. Both are best-effort: the shell must still render if the
// registry answers the tenant but not these, so a failure degrades to an
// empty product grid rather than a 404 on the whole page.
const now = Date.now();
const [catalog, entitlements] = await Promise.all([
fetchCatalog().catch(() => [] 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 // Minimal shim so the shell can render. Design-rich fields fall back to
// placeholders that won't blow up the layout. // placeholders that won't blow up the layout.
return { return {
@@ -51,12 +111,12 @@ export async function loadTenantForShell(slug: string): Promise<PortalTenant | n
contactEmail: "—", contactEmail: "—",
renewal: "—", renewal: "—",
since: live.created_at?.slice(0, 10) ?? "—", since: live.created_at?.slice(0, 10) ?? "—",
entitled: [], entitled,
trialing: [], trialing,
trialEnds: live.trial_ends_at ?? undefined, trialEnds: live.trial_ends_at ?? undefined,
seed: 0, seed: 0,
findingCount: 0, findingCount: 0,
products: [], products,
findings: [], findings: [],
activity: [], activity: [],
audit: [], audit: [],