feat/soft-launch
needs attentionviewing older commit682c40c · incrementalpre-PRreviewed 2026-07-07 01:21 UTC5H · 4M · 6L · 2I- Purpose
- Gate the platform to a bills+credentials-only soft launch: keep the not-yet-ready surfaces landed by PR #207 (Sites/exception-engine, Assets/metrics, Tarifas, savings) dark until each module is ready to reveal.
- Goal
- Sidebar shows only Bills and Credentials as active; all other top-level modules are greyed-out or hidden. Direct navigation to gated routes redirects to /bills/contratos. Post-login landing always hits a live page.
- Sub-goals
- SG-1: SSOT lib/soft-launch.ts — isGatedPath/softLaunchNav + sidebar grey-out (Sites/Tarifas) / hide (Assets), admin bypass
- SG-2: <SoftLaunchGuard> mounted once in (dashboard)/layout.tsx; /credentials/metrics tab gated
- SG-3: login default + bare /dashboard → /bills/contratos
- What
- Added soft-launch SSOT module (soft-launch.ts) with GATED_PREFIXES, GATED_NAV, isGatedPath, and softLaunchNav. Added SoftLaunchGuard client component mounted in dashboard layout. Updated DashboardSidebar to read softLaunchNav for greyed/hidden treatment. Updated credentials layout to gate metrics tab. Changed login default and dashboard redirect to SOFT_LAUNCH_LANDING. Added comprehensive unit tests.
- Why
- PR #207 landed Sites, Assets, Tarifas, and metrics surfaces ahead of them being customer-ready. The soft-launch gate keeps those routes dark while making Bills + Credentials the only live surfaces, using a single SSOT so nav grey-out and route reachability can never drift.
- Areas
- apps/platform/src/lib+231−0apps/platform/src/components+60−2apps/platform/src/app+31−11apps/platform/CLAUDE.md+2−0.branch/scope.md+21−1
- Blast
- 10 files, +345/−14; frontend-only (apps/platform). No domains, packages, API contracts, DB, or infra touched.
Findings · 18
correctness3
dashboard/page.tsx redirects unconditionally to SOFT_LAUNCH_LANDING
apps/platform/src/app/[locale]/(dashboard)/dashboard/page.tsx:15
redirect({ href: SOFT_LAUNCH_LANDING, locale }) fires regardless of SOFT_LAUNCH's value. When the team sets SOFT_LAUNCH = false to lift the gate, /dashboard will still redirect to /bills/contratos instead of restoring its prior target. Fix: redirect to SOFT_LAUNCH ? SOFT_LAUNCH_LANDING : '/bills'.
getSafeRedirectUrl fallback hardcoded to SOFT_LAUNCH_LANDING regardless of SOFT_LAUNCH
apps/platform/src/app/[locale]/(auth)/login/page.tsx:43
Both fallback return branches unconditionally return SOFT_LAUNCH_LANDING. When SOFT_LAUNCH = false, post-login users with no redirectTo param will still land on /bills/contratos instead of /dashboard. Fix: return SOFT_LAUNCH ? SOFT_LAUNCH_LANDING : '/dashboard' in both branches.
SoftLaunchGuard locale extraction breaks with localePrefix: as-needed
apps/platform/src/components/SoftLaunchGuard.tsx:31
const locale = pathname.split('/')[1] || 'en' assumes locale is always at position 1. With localePrefix: 'as-needed' the default locale (en) has no URL prefix — /sites/foo yields split('/')[1] = 'sites', so locale = 'sites' and the redirect becomes /sites/bills/contratos (a 404). Simultaneously, stripLocale treats 'sites' as the locale and returns '/', so isGatedPath returns false — the guard silently fails to block English-locale users from gated routes. Fix: use useParams() or pass params.locale from the server layout.
security2
Post-login ?redirectTo can target gated routes — SoftLaunchGuard is the only backstop
apps/platform/src/app/[locale]/(auth)/login/page.tsx:41
getSafeRedirectUrl validates relative path syntax but not whether the destination is gated. A crafted ?redirectTo=/en/sites link is valid and router.push() will navigate there. SoftLaunchGuard synchronously returns null before children render, so no content is exposed — but defense-in-depth suggests also checking isGatedPath() in getSafeRedirectUrl and substituting SOFT_LAUNCH_LANDING when true.
SSR renders children before client guard fires — inherent in client-component guard pattern
apps/platform/src/components/SoftLaunchGuard.tsx:25
On first SSR/hydration the server renders children before the client guard's synchronous null check. JS-disabled users or SSR-HTML inspection can see page structure for gated routes. Accepted: this is a UX-only gate; API + RLS protects real data.
conventions5
Client-side guard pattern departs from established per-segment server-side layout guard
apps/platform/src/components/SoftLaunchGuard.tsx:1
The project's existing pattern (metrics/layout.tsx) uses server-side layout files with notFound() — no client flash. SoftLaunchGuard is a useClient component that redirects in useEffect, potentially flashing blank content. The pattern change is justified in scope.md ('central guard > per-segment') but this is not reflected in an ADR or a comment in the file itself. The reviewer should confirm this deliberate deviation is acceptable.
Hardcoded SOFT_LAUNCH constant bypasses the documented NEXT_PUBLIC_HIDE_* kill-switch pattern
apps/platform/src/lib/soft-launch.ts:1
The project's feature-flags pattern uses NEXT_PUBLIC_HIDE_* env vars for toggles without a deploy. SOFT_LAUNCH is a hardcoded boolean — lifting the gate requires a code change + redeploy. This is intentional (documented in scope.md) but no ADR or CLAUDE.md cross-reference explains why the established pattern was bypassed. apps/platform/CLAUDE.md now documents this (in the diff) which partially addresses it.
credentials/layout.tsx mixes env-var and hardcoded flag patterns in one expression
apps/platform/src/app/[locale]/(dashboard)/credentials/layout.tsx:30
showMetricsTab = !ASSET_MANAGEMENT_HIDDEN && (!SOFT_LAUNCH || isPlatformAdmin) couples two different flag patterns in one expression. When SOFT_LAUNCH is removed, this expression is non-obvious to clean up. Better: use softLaunchNav('credentials/metrics', { isPlatformAdmin }) and add the key to GATED_NAV, keeping all soft-launch logic in the SSOT.
disabled: sitesNav === 'greyed' || undefined — implicit type coercion anti-pattern
apps/platform/src/components/DashboardSidebar.tsx:89
boolean || undefined is a common idiom but TypeScript strict mode prefers explicit handling. The intent is to pass undefined (not false) when not greyed. Write disabled: sitesNav === 'greyed' ? true : undefined to make the intent explicit and survive a future prop type change.
stripLocale returns '/' for edge inputs without documenting the contract
apps/platform/src/lib/soft-launch.ts:21
The function's behavior for '', '/en', and no-slash inputs is implicit. Low-risk because usePathname() always returns locale-prefixed paths in production, but a brief comment on the <= 2 guard would clarify the contract for future readers.
tests4
getSafeRedirectUrl is an open-redirect guard with no tests
apps/platform/src/app/[locale]/(auth)/login/page.tsx:41
The function blocks javascript:, protocol-relative //, absolute URLs, and data: schemes. The logic (indexOf(':') > indexOf('/')) is non-obvious and easy to regress. Inputs like 'javascript:alert(1)', '//evil.com', and 'http://evil.com' should be tested. The function is not exported so tests would need to be co-located or the function exported.
SOFT_LAUNCH = false branch (the teardown path) never exercised
apps/platform/src/lib/__tests__/soft-launch.test.ts:1
Both isGatedPath and softLaunchNav short-circuit on !SOFT_LAUNCH. These branches represent the entire reversibility promise and are not tested. Since SOFT_LAUNCH is a module-level constant, tests would need vi.mock or test the logic directly via a helper that accepts SOFT_LAUNCH as a parameter. Without coverage, a regression that breaks gate removal goes undetected.
isPlatformAdmin = null not covered
apps/platform/src/lib/__tests__/soft-launch.test.ts:54
Functions accept boolean | null. Tests cover true (bypass) and implicit false/undefined (gated). One assertion — isGatedPath('/en/sites', { isPlatformAdmin: null }) === true — would pin the null-is-gated contract.
stripLocale edge cases (empty string, no-slash) not covered
apps/platform/src/lib/__tests__/soft-launch.test.ts:13
Both return '/' per the parts.length <= 2 guard. Low risk in production but two assertions would document the contract for future refactors.
improvement4
GATED_PREFIXES and GATED_NAV are parallel structures that can drift
apps/platform/src/lib/soft-launch.ts:7
Adding a new gated module requires touching both arrays. A module added to GATED_NAV but missed in GATED_PREFIXES would grey the nav link but not block direct URL access. Consider deriving GATED_PREFIXES from GATED_NAV: const GATED_PREFIXES = Object.keys(GATED_NAV).map(k => `/${k}`) — making GATED_NAV the single source and path guard stays in sync. Note: /credentials/metrics must stay explicit since its parent /credentials is live.
SoftLaunchGuard could redirect inline rather than via useEffect
apps/platform/src/components/SoftLaunchGuard.tsx:18
router.replace in a useEffect fires after paint. Since gated is synchronous, calling router.replace directly before returning null avoids the async hop. Note: Next.js recommends useEffect for router calls to avoid issues in concurrent mode — keep effect if there's uncertainty about the render model.
Locale extraction is duplicated between SoftLaunchGuard and the app routing
apps/platform/src/components/SoftLaunchGuard.tsx:31
The guard re-derives locale from pathname.split('/')[1]. Exporting a getLocale(pathname: string): string helper from soft-launch.ts (or using useParams()) would give one canonical implementation and avoid the two diverging.
credentials/layout.tsx imports raw SOFT_LAUNCH instead of routing through softLaunchNav
apps/platform/src/app/[locale]/(dashboard)/credentials/layout.tsx:6
This is the only callsite that bypasses the SSOT helpers. Adding a 'credentials/metrics' entry to GATED_NAV and using softLaunchNav('credentials/metrics', { isPlatformAdmin }) would make removal mechanical — delete the entry and all callsites update automatically.