M5.2 in full: 10 customer-area route shells + a role-gated nav + a backstage stub.
Routes (under /[slug]/): products, projects, catalog, settings, settings/users, settings/api-keys, settings/integrations, billing, audit, support. Each renders an empty-state pointing at the milestone that ships its real content. 403 if the user's org_roles don't grant the surface (per the matrix in src/lib/session.ts).
Nav component reads session.org_roles and filters links so users only see what they're allowed to access (PLATFORM_ARCHITECTURE.md §5a 'hide what the user can't access').
Dashboard now reads session.products and renders one tile per entitlement. Empty-state links into the catalog flow.
Backstage stub at /__backstage__ — middleware already rewrites backstage.<apex>/* to this prefix. Real RBAC against BREAKPILOT_ADMIN / SUPPORT_ENGINEER / SALES_REP lands in M13.2.
Tenant-slug match guard in [slug]/layout.tsx: a session with tenant_slug=A trying to view /B/... gets redirected to /A/dashboard. Defence-in-depth; the real guard is at the API layer (M4.3).
Why
After M4.1–M4.3 there's a real backend that knows everything about a tenant + their entitlements + their KC user. M5.2 makes the portal actually navigable. Every M10.x / M11.x / M12.x / M14.x milestone will replace one of these shells with real content — by landing the URLs + RBAC gates now, none of those PRs needs to touch routing.
Linked milestone: M5.2
How
src/lib/session.ts is the single source of truth for the role → surface matrix. canSee(session, surface) is the only check pages do — never enumerate roles inline. 13 vitest cases assert every role × surface combination.
Per-route shells use a shared <ShellEmpty title description milestone /> component so every surface looks identical and the cross-link to the right milestone is consistent.
Backstage path is a deliberate ugly /__backstage__/ so it can't be confused with a tenant slug (the host parser already filters it via { kind: 'backstage' }, but the path prefix is the second wall).
All four CI gates green locally: pnpm lint + typecheck + test --coverage (100% on src/lib) + build. 24 tests now, was 13.
pnpm build — all routes compile in standalone mode
Manual: bring up dev stack + tenant-registry, log in as test@breakpilot.dev (IT_ADMIN), see settings/api-keys/billing in nav; log in as a USER, see only dashboard/products/support
Risk
Blast radius: portal-only. Defaults are conservative — shells render "this is M-whatever" placeholders, no destructive surfaces.
What could break:
The role matrix is hand-curated. If a future role (e.g., DPO for audit-only) needs a different combination, update src/lib/session.ts + add a test case.
canSee() is the only RBAC primitive in the portal. It is not an auth check — it just hides links. The API layer (tenant-registry M4.3+) does the real enforcement. A motivated user pasting URLs gets to the page; without a valid JWT scope the API returns 401/403 and the page renders an error state.
Unit tests added (13 new for session, exhaustive role × surface)
Docs updated (CHANGELOG)
Secrets via Infisical — n/a (no new secrets)
Migration — n/a
Tenant scoping — slug-match guard in layout + canSee on every page
OpenAPI spec — n/a (consumer)
CHANGELOG entry under "Added"
## What
M5.2 in full: 10 customer-area route shells + a role-gated nav + a backstage stub.
- **Routes** (under `/[slug]/`): products, projects, catalog, settings, settings/users, settings/api-keys, settings/integrations, billing, audit, support. Each renders an empty-state pointing at the milestone that ships its real content. 403 if the user's `org_roles` don't grant the surface (per the matrix in `src/lib/session.ts`).
- **Nav** component reads `session.org_roles` and filters links so users only see what they're allowed to access (PLATFORM_ARCHITECTURE.md §5a 'hide what the user can't access').
- **Dashboard** now reads `session.products` and renders one tile per entitlement. Empty-state links into the catalog flow.
- **Backstage stub** at `/__backstage__` — middleware already rewrites `backstage.<apex>/*` to this prefix. Real RBAC against BREAKPILOT_ADMIN / SUPPORT_ENGINEER / SALES_REP lands in M13.2.
- **Tenant-slug match guard** in `[slug]/layout.tsx`: a session with `tenant_slug=A` trying to view `/B/...` gets redirected to `/A/dashboard`. Defence-in-depth; the real guard is at the API layer (M4.3).
## Why
After M4.1–M4.3 there's a real backend that knows everything about a tenant + their entitlements + their KC user. M5.2 makes the portal actually navigable. Every M10.x / M11.x / M12.x / M14.x milestone will replace one of these shells with real content — by landing the URLs + RBAC gates now, none of those PRs needs to touch routing.
Linked milestone: **M5.2**
## How
- **`src/lib/session.ts`** is the single source of truth for the role → surface matrix. `canSee(session, surface)` is the only check pages do — never enumerate roles inline. 13 vitest cases assert every role × surface combination.
- **Per-route shells** use a shared `<ShellEmpty title description milestone />` component so every surface looks identical and the cross-link to the right milestone is consistent.
- **Backstage** path is a deliberate ugly `/__backstage__/` so it can't be confused with a tenant slug (the host parser already filters it via `{ kind: 'backstage' }`, but the path prefix is the second wall).
- All four CI gates green locally: `pnpm lint` + `typecheck` + `test --coverage` (100% on `src/lib`) + `build`. 24 tests now, was 13.
## Test plan
- [x] `pnpm lint` clean
- [x] `pnpm typecheck` clean
- [x] `pnpm test` — 24 tests, 100% `src/lib` coverage (host + session + tenant-registry)
- [x] `pnpm build` — all routes compile in standalone mode
- [ ] Manual: bring up dev stack + tenant-registry, log in as test@breakpilot.dev (IT_ADMIN), see settings/api-keys/billing in nav; log in as a USER, see only dashboard/products/support
## Risk
**Blast radius:** portal-only. Defaults are conservative — shells render "this is M-whatever" placeholders, no destructive surfaces.
**What could break:**
- The role matrix is hand-curated. If a future role (e.g., DPO for audit-only) needs a different combination, update `src/lib/session.ts` + add a test case.
- `canSee()` is the only RBAC primitive in the portal. **It is not an auth check** — it just hides links. The API layer (tenant-registry M4.3+) does the real enforcement. A motivated user pasting URLs gets to the page; without a valid JWT scope the API returns 401/403 and the page renders an error state.
**Rollback plan:** revert. Existing portal routes (`/` apex, `/[slug]`, `/[slug]/dashboard`) keep working.
## Checklist
- [x] Unit tests added (13 new for session, exhaustive role × surface)
- [x] Docs updated (CHANGELOG)
- [x] Secrets via Infisical — n/a (no new secrets)
- [ ] Migration — n/a
- [x] Tenant scoping — slug-match guard in layout + canSee on every page
- [ ] OpenAPI spec — n/a (consumer)
- [x] CHANGELOG entry under "Added"
Adds the M5.2 surface set per PLATFORM_ARCHITECTURE.md §5a. Every route
is a navigable skeleton with a per-route empty-state pointing at the
milestone that ships the real content; the Nav component filters links
by session.org_roles so an IT_ADMIN sees settings + api-keys, a CXO
sees billing, a USER sees only dashboard + products + support, etc.
New surfaces (10):
/[slug]/products M10.1
/[slug]/projects M10.1
/[slug]/catalog M11.1
/[slug]/settings M10.1
/[slug]/settings/users M10.1
/[slug]/settings/api-keys M15.1
/[slug]/settings/integrations M15.2
/[slug]/billing M8.3
/[slug]/audit M10.2
/[slug]/support M9.1
Dashboard upgraded: reads session.products, renders one tile per
entitled product (real tile content lands in M10.1). Empty-state when
the user has no entitlements yet — links into the catalog flow.
Backstage stub at /__backstage__ — middleware already rewrites
backstage.<apex>/* to this prefix; real RBAC against BREAKPILOT_ADMIN /
SUPPORT_ENGINEER / SALES_REP lands in M13.2.
Layout enforces tenant-slug match: a session with tenant_slug=A trying
to view /B/... gets redirected to /A/dashboard. Prevents JWT-replay
across tenants (defence in depth; the real guard is at the API layer,
which M4.3 adds in tenant-registry).
src/lib/session.ts is the single source of truth for the role matrix
+ canSee(surface) helper. 13 vitest cases, 100% coverage of src/lib.
Refs: M5.2
CODEOWNERS rules
requested review from Benjamin_Boenisch 2026-05-19 11:55:14 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
M5.2 in full: 10 customer-area route shells + a role-gated nav + a backstage stub.
/[slug]/): products, projects, catalog, settings, settings/users, settings/api-keys, settings/integrations, billing, audit, support. Each renders an empty-state pointing at the milestone that ships its real content. 403 if the user'sorg_rolesdon't grant the surface (per the matrix insrc/lib/session.ts).session.org_rolesand filters links so users only see what they're allowed to access (PLATFORM_ARCHITECTURE.md §5a 'hide what the user can't access').session.productsand renders one tile per entitlement. Empty-state links into the catalog flow./__backstage__— middleware already rewritesbackstage.<apex>/*to this prefix. Real RBAC against BREAKPILOT_ADMIN / SUPPORT_ENGINEER / SALES_REP lands in M13.2.[slug]/layout.tsx: a session withtenant_slug=Atrying to view/B/...gets redirected to/A/dashboard. Defence-in-depth; the real guard is at the API layer (M4.3).Why
After M4.1–M4.3 there's a real backend that knows everything about a tenant + their entitlements + their KC user. M5.2 makes the portal actually navigable. Every M10.x / M11.x / M12.x / M14.x milestone will replace one of these shells with real content — by landing the URLs + RBAC gates now, none of those PRs needs to touch routing.
Linked milestone: M5.2
How
src/lib/session.tsis the single source of truth for the role → surface matrix.canSee(session, surface)is the only check pages do — never enumerate roles inline. 13 vitest cases assert every role × surface combination.<ShellEmpty title description milestone />component so every surface looks identical and the cross-link to the right milestone is consistent./__backstage__/so it can't be confused with a tenant slug (the host parser already filters it via{ kind: 'backstage' }, but the path prefix is the second wall).pnpm lint+typecheck+test --coverage(100% onsrc/lib) +build. 24 tests now, was 13.Test plan
pnpm lintcleanpnpm typecheckcleanpnpm test— 24 tests, 100%src/libcoverage (host + session + tenant-registry)pnpm build— all routes compile in standalone modeRisk
Blast radius: portal-only. Defaults are conservative — shells render "this is M-whatever" placeholders, no destructive surfaces.
What could break:
src/lib/session.ts+ add a test case.canSee()is the only RBAC primitive in the portal. It is not an auth check — it just hides links. The API layer (tenant-registry M4.3+) does the real enforcement. A motivated user pasting URLs gets to the page; without a valid JWT scope the API returns 401/403 and the page renders an error state.Rollback plan: revert. Existing portal routes (
/apex,/[slug],/[slug]/dashboard) keep working.Checklist
Pull request closed