← all branches

feat/mod-gates

needs attentionviewing older commit
3810b89 · fullPR #294reviewed 2026-07-10 16:32 UTC7H · 10M · 10L · 1I
The branch
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)
The changes (whole branch)
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+4418apps/platform/src/lib/__tests__/soft-launch.test.ts+505apps/platform/src/components/SoftLaunchGuard.tsx+216apps/platform/src/components/DashboardSidebar.tsx+138apps/platform/src/app/[locale]/(dashboard)/layout.tsx+71apps/platform/src/app/[locale]/(dashboard)/admin/_components/OrgDetailPanel.tsx+72packages/api/src/schemas/organization.schemas.ts+51domains/core/src/organization/organization.type.ts+11
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.
verified-prod-bug client-side-gate-only api-enforcement-deferred
ci· GitHub Checks API not accessible with current token scopecoderabbit· No .coderabbit.yaml found

Findings · 27

correctness5

high

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.

high

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.

medium

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.

medium

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.

low

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

high

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.

medium

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.

low

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

medium

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.

low

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.

low

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

high

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.

high

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).

medium

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.

medium

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.

medium

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.

medium

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.

low

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.

low

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

high

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.

high

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.

medium

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.

medium

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.

medium

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.

low

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.

low

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.

low

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.

History · 2 commits

  1. f9f2669needs attentionincremental0H · 2M · 4L2026-07-10 16:48
  2. 3810b89needs attentionfull7H · 10M · 10L2026-07-10 16:32current