feat/mod-gates
needs attentionviewing older commit3810b89 · fullPR #294reviewed 2026-07-10 16:32 UTC7H · 10M · 10L · 1I- Purpose
- Fix the prod bug where enabling a module (tarifas/sites/assets) in the admin panel had no effect for that org's regular users — the gate never consulted entitlements, and platform admins bypass the gate so the bug was masked.
- Goal
- Make the soft-launch gate entitlement-aware (per-org reveal) and extend entitlement toggles to all three gated modules.
- Sub-goals
- SG-1: Widen ORG_MODULES + OrgModuleKeySchema to include sites and assets in lockstep
- SG-2: Make isGated/navState entitlement-aware; wire SoftLaunchGuard, layout, sidebar, admin toggles; add tests
- SG-3: Preview verification with non-admin user (pending)
- What
- soft-launch.ts SSOT now maps gated prefixes to owning modules; isGated/navState accept enabledModules; SoftLaunchGuard holds redirect until org query resolves; sidebar passes orgModules to navState; admin panel adds Sites/Assets toggles; ORG_MODULES and OrgModuleKeySchema widened to 3 modules; 14 new unit tests.
- Why
- Root cause: gate checked GATED_PATHS without consulting org.settings.modules. Entitlement data was stored correctly (5 prod orgs with modules.tarifas=true) but never read by the gate.
- Areas
- apps/platform/src/lib/soft-launch.ts+44−18apps/platform/src/lib/__tests__/soft-launch.test.ts+50−5apps/platform/src/components/SoftLaunchGuard.tsx+21−6apps/platform/src/components/DashboardSidebar.tsx+13−8apps/platform/src/app/[locale]/(dashboard)/layout.tsx+7−1apps/platform/src/app/[locale]/(dashboard)/admin/_components/OrgDetailPanel.tsx+7−2packages/api/src/schemas/organization.schemas.ts+5−1domains/core/src/organization/organization.type.ts+1−1
- Blast
- 8 files, +148/-42 lines; touches soft-launch SSOT, dashboard guard, sidebar, admin panel, domain type, and API schema. New branch/scope files are docs-only.
Findings · 27
correctness5
credentials/layout.tsx calls isGated without enabledModules — Metrics tab stays hidden for entitled orgs
apps/platform/src/app/[locale]/(dashboard)/credentials/layout.tsx:31
Line 31 calls `isGated('/credentials/metrics', isPlatformAdmin)` with the two-arg signature (enabledModules defaults to []). An org entitled to the 'assets' module gets isGated=true here, so the Metrics tab is hidden even though SoftLaunchGuard correctly lets them through to the route. The route guard and the tab visibility are now inconsistent.
DashboardSidebar gets enabledModules=[] (not undefined) while org query is in flight
apps/platform/src/app/[locale]/(dashboard)/layout.tsx:150
DashboardSidebar is always passed `org?.enabledModules ?? []`. While orgResponse is in flight, org is null and this evaluates to []. The sidebar briefly renders entitled-org users with zero entitlements — a flash of collapsed nav. SoftLaunchGuard was correctly given the undefined-while-loading treatment; the sidebar was not.
SoftLaunchGuard renders null while entitlements resolve — blank content area on cold deep links
apps/platform/src/components/SoftLaunchGuard.tsx:43
When enabledModules is undefined, isGated receives [] and returns true for any gated path. The guard's `if (gated) return null` suppresses children immediately. Entitled users on cold deep links see a blank content area until the org query resolves. No loading indicator is shown during the hold.
orgResponse truthy check conflates loading with error — transient query error bounces entitled users
apps/platform/src/app/[locale]/(dashboard)/layout.tsx:177
`orgResponse ? (org?.enabledModules ?? []) : undefined` passes [] (not undefined) on non-200 responses. A transient error causes the guard to fire router.replace(SOFT_LAUNCH_LANDING) before a query retry can succeed, bouncing an entitled user off their deep link.
assets module collapses /assets and /metrics into one entitlement — indivisible today, fragile tomorrow
apps/platform/src/lib/soft-launch.ts
Both /metrics and /assets map to 'assets'. Enabling assets reveals both routes atomically. If a future requirement separates them, the schema and path map need coordinated changes.
security3
Client-only gate for sites/assets — API data reachable via direct HTTP regardless of entitlement
apps/platform/src/components/SoftLaunchGuard.tsx
The gate is a React component. Any authenticated org member can call GET /api/sites or /api/assets directly; ensureModuleEntitled is not called in those handlers. This is explicitly documented as out-of-scope and intentional during soft launch, but becomes a revenue-bypass vector if these modules go commercial before API enforcement is added.
Stale isGated call in credentials/layout.tsx is also a trust-boundary inconsistency
apps/platform/src/app/[locale]/(dashboard)/credentials/layout.tsx:31
Route guard (SoftLaunchGuard) uses entitlements; tab visibility check does not. These are inconsistent trust boundaries — the route is open for entitled orgs but the tab is hidden, creating a confusing state for users and support.
enabledModules sourced from client-side query, not JWT — UI bypass trivially possible via devtools
apps/platform/src/app/[locale]/(dashboard)/layout.tsx
Acceptable for a UI-only gate during soft launch. Becomes a risk if paid enforcement is not added before commercial launch of sites/assets.
conventions3
OrgModuleOptionKey duplicates OrgModuleKey — second source of truth
apps/platform/src/app/[locale]/(dashboard)/admin/_components/OrgDetailPanel.tsx
The local `type OrgModuleOptionKey = 'tarifas' | 'sites' | 'assets'` re-declares the canonical `OrgModuleKey` from the domain. Adding a 4th module to ORG_MODULES won't automatically update this type; import `OrgModuleKey` from `@batu/core` instead.
GATED_PATH_MODULES tuple elements not typed against OrgModuleKey — typos compile silently
apps/platform/src/lib/soft-launch.ts
The second element of each tuple is a string, not typed as OrgModuleKey. A mistyped module key would compile without error but silently fail to gate.
enabledModules props typed as string[] rather than OrgModuleKey[]
apps/platform/src/components/SoftLaunchGuard.tsx
Loose string[] typing vs the domain's OrgModuleKey union. Should use the precise type throughout for type-safety and IDE discoverability.
tests8
SoftLaunchGuard hold-on-undefined has no test
apps/platform/src/lib/__tests__/soft-launch.test.ts
The key race-condition guard (hold redirect until enabledModules !== undefined) is untested. A regression here would flash-redirect entitled users before their entitlements load, causing the exact bug this PR fixes.
Empty enabledModules list [] not tested — distinct from undefined
apps/platform/src/lib/__tests__/soft-launch.test.ts
isGated(route, false, []) and navState(key, false, []) are not tested. Empty array is a valid runtime state (org has no granted entitlements yet) distinct from undefined (query in flight).
SOFT_LAUNCH=false interaction with enabledModules not tested
apps/platform/src/lib/__tests__/soft-launch.test.ts
No test verifies that SOFT_LAUNCH=false renders enabledModules a no-op.
Admin bypass + entitlement combo not tested
apps/platform/src/lib/__tests__/soft-launch.test.ts
isGated / navState with both isPlatformAdmin=true and non-empty enabledModules is untested.
Locale-prefixed paths not tested with entitlement bypass
apps/platform/src/lib/__tests__/soft-launch.test.ts
New entitlement tests only use locale-free paths. If locale stripping interacts poorly with the module lookup, entitled orgs on locale-prefixed routes would stay gated.
navState(key, false, undefined) not tested as distinct from omitting param
apps/platform/src/lib/__tests__/soft-launch.test.ts
Passing undefined explicitly vs omitting the parameter should be equivalent (default=[]) but is not asserted.
isGated multi-module entitlement set not tested
apps/platform/src/lib/__tests__/soft-launch.test.ts
navState has a multi-module test (['sites','assets']); isGated does not. Minor gap.
moduleRoute completeness not validated — new modules could be silently excluded from the invariant
apps/platform/src/lib/__tests__/soft-launch.test.ts
No assertion ensures moduleRoute keys match ORG_MODULES. A new module added to ORG_MODULES but omitted from moduleRoute would silently escape the invariant.
improvement8
OrgModuleOptionKey is a local re-declaration of OrgModuleKey (DRY violation)
apps/platform/src/app/[locale]/(dashboard)/admin/_components/OrgDetailPanel.tsx:76
Import OrgModuleKey from @batu/core-domain instead.
ORG_MODULE_OPTIONS is a third manual enumeration of the module list
apps/platform/src/app/[locale]/(dashboard)/admin/_components/OrgDetailPanel.tsx:77
ORG_MODULES (domain) + GATED_PATH_MODULES (soft-launch) + ORG_MODULE_OPTIONS (admin panel) are three unlinked lists. Adding a 4th module requires three edits with no compile-time connection.
enabledModules coalesced twice in DashboardSidebar
apps/platform/src/components/DashboardSidebar.tsx:78
`const orgModules = enabledModules ?? []` (line 78) and `const modules = enabledModules ?? []` (line ~151) are two aliases for the same value. Declare once.
requiredModule ternary inverts intuitive entitlement semantics
apps/platform/src/components/DashboardSidebar.tsx:93
`requiredModule: navState === 'greyed' ? undefined : 'sites'` — set when nav is 'live', absent when 'greyed'. This backwards guard will confuse maintainers adding a 5th module.
DashboardSidebar and SoftLaunchGuard receive inconsistent undefined-vs-empty semantics for enabledModules
apps/platform/src/app/[locale]/(dashboard)/layout.tsx:150
Sidebar gets `org?.enabledModules ?? []` (never undefined); guard gets `orgResponse ? (org?.enabledModules ?? []) : undefined`. Unify: pass undefined-while-loading to both.
GREYED / HIDDEN sets are a second source of truth for nav treatment (vs GATED_PATH_MODULES)
apps/platform/src/lib/soft-launch.ts
Consider encoding the treatment directly in GATED_PATH_MODULES as a triple [path, moduleKey, treatment] to avoid drift.
navState returns 'live' for unknown moduleKey — typos bypass the gate silently
apps/platform/src/lib/soft-launch.ts
With SOFT_LAUNCH=true, `navState('asset')` (typo) returns 'live' without warning. A dev-mode assertion for unrecognized keys would surface drift early.
SoftLaunchGuard renders null with no loading indicator during hold
apps/platform/src/components/SoftLaunchGuard.tsx:43
null render while entitlements resolve is intentional but invisible to users. A minimal loading state would be a better UX.