← all branches

feat/soft-launch

needs attentionviewing older commit
682c40c · incrementalpre-PRreviewed 2026-07-07 01:21 UTC5H · 4M · 6L · 2I
The branch
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
The changes (whole branch)
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+2310apps/platform/src/components+602apps/platform/src/app+3111apps/platform/CLAUDE.md+20.branch/scope.md+211
Blast
10 files, +345/−14; frontend-only (apps/platform). No domains, packages, API contracts, DB, or infra touched.
SOFT_LAUNCH=true (hardcoded, lift by setting to false) no PR open yet
CI / GitHub checks· No PR open — check status unavailableCodeRabbit· No .coderabbit.yaml in repo

Findings · 18

correctness3

high

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

high

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.

medium

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

low

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.

info

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

high

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.

high

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.

medium

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.

medium

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.

low

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

high

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.

medium

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.

low

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.

low

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

low

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.

low

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.

low

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.

info

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.

History · 4 commits

  1. 3709e2eneeds attentionincremental0H · 3M · 3L2026-07-07 02:18
  2. a4d7b63needs attentionfull1H · 4M · 4L2026-07-07 01:39
  3. 682c40cneeds attentionincremental5H · 4M · 6L2026-07-07 01:21current
  4. 0385797needs attentionfull7H · 15M · 10L2026-07-06 19:45