feat/soft-launch
needs attentionviewing older commit0385797 · fullpre-PRreviewed 2026-07-06 19:45 UTC7H · 15M · 10L · 4I- Purpose
- Gate the platform to a bills + credentials-only soft launch, keeping not-yet-ready surfaces (Sites, Assets, Tarifas, Metrics, Analytics, Reports) dark until they are ready to reveal one module at a time.
- Goal
- Grey-out unready top-level modules as coming-soon ('Pronto') and guard their routes so direct navigation always redirects to /bills/contratos — unreachable, not just hidden.
- Sub-goals
- SG-1: Soft-launch SSOT (soft-launch.ts) + sidebar grey-out — disabled: true on Sites/Assets/Tarifas, admin bypass
- SG-2: Route guards on gated segments (sites, assets, tarifas, metrics, dashboard layouts) redirecting to /bills/contratos
- SG-3: Landing safety — login default + all hardcoded /dashboard references changed to /bills/contratos
- SG-N: Framework learnings folded back into product-ux.md / platform CLAUDE.md
- What
- Branch provisioning only — added .branch/scope.md (143 lines, full implementation plan) and empty .branch/intent.md. No application code yet.
- Why
- PR #207 landed Sites/exception-engine, Assets/metrics, Tarifas, savings/analytics/reports code into the platform bundle; this branch gates their user-reachability before the features are ready for external exposure.
- Areas
- .branch+143−0
- Blast
- 1 file (+143 lines) — scope planning doc only. No application code changed.
Findings · 33
correctness7
Client-component guards flash gated content before redirecting
apps/platform/src/app/[locale]/(dashboard)/sites/layout.tsx
In Next.js App Router, 'use client' layouts run after HTML is streamed — redirect() fires after mount, so the gated page renders for a frame first. The page is not truly unreachable. Hard route guards require a server component layout (calls redirect() before any rendering) or Next.js middleware. Since DashboardLayout is already 'use client', the guard must either be factored into a nested server component or moved to middleware.
Admin bypass races with async OrganizationContext — admins may be wrongly redirected
apps/platform/src/lib/soft-launch.ts
isPlatformAdmin comes from useOrgContext(), which is async. During isLoading, isPlatformAdmin defaults to false — the guard fires and redirects an admin to /bills/contratos before the context resolves. A middleware-based guard (reads verified JWT server-side) eliminates this race entirely.
getSafeRedirectUrl passes gated redirectTo through — causes redirect loops
apps/platform/src/app/[locale]/(auth)/login/page.tsx
If a user's session expired while on /sites, the ?redirectTo=/sites param passes through getSafeRedirectUrl, lands them back on /sites after login, which then redirects to /bills/contratos — a redirect loop. Fix: pipe redirectTo through isGatedPath() and substitute the live landing href when gated.
dashboard/layout.tsx guard is dead code — dashboard/page.tsx already redirects server-side
apps/platform/src/app/[locale]/(dashboard)/dashboard/layout.tsx
dashboard/page.tsx unconditionally calls redirect() at render time (server component). The client-side layout.tsx guard fires after this server redirect has already resolved — it will never execute. The layout guard either needs to be a server component, or the page.tsx redirect is sufficient and no new layout.tsx is needed for /dashboard.
Hardcoded SSOT lacks emergency rollback mechanism
apps/platform/src/lib/soft-launch.ts
The scope prefers hardcoded SSOT over NEXT_PUBLIC_* env vars. Valid for module-reveal cadence. But if the gate needs to be lifted urgently (customer blocked), it requires code edit → PR → deploy vs a Vercel env var flip. The current text presents this as unambiguously better — the emergency-rollback tradeoff should be explicitly acknowledged.
ASSETS_HIDDEN per-page guards remain after D3 sidebar change — inconsistent state in dev
apps/platform/src/app/[locale]/(dashboard)/assets/layout.tsx
D3 drops hidden: ASSETS_HIDDEN from the sidebar but individual asset pages still check ASSETS_HIDDEN. In dev where ASSETS_HIDDEN=false, Assets is shown as Pronto (unclickable) but still reachable by direct URL. The layout guard in assets/layout.tsx should supersede the per-page guards.
Tarifas disabled:true reveals the module to orgs that previously never saw it
apps/platform/src/app/[locale]/(dashboard)/tarifas/layout.tsx
Tarifas today is hidden via requiredModule: 'tarifas' — orgs without that entitlement never see it. D3 replaces hidden with disabled: true (Pronto). Orgs that never had Tarifas will now see a coming-soon badge for a module they were never shown. This is a UX behaviour change outside the stated scope — worth calling out.
security4
API endpoints for gated modules must be audited before shipping
The scope states 'UX reachability only — the API already enforces auth + entitlements.' This is architecturally correct IF every gated module's API handlers enforce server-side org/role scoping. An authenticated user can still call the API directly. Each of /sites, /assets, /tarifas, /metrics endpoint families must be confirmed to have proper entitlement enforcement before this ships.
isPlatformAdmin evaluated client-side is spoofable if derived from client-accessible state
apps/platform/src/lib/soft-launch.ts
If isPlatformAdmin is derived from a client-accessible claim (e.g. unverified JWT, localStorage), a user could manipulate it to bypass the gate. The bypass should be enforced server-side (middleware reading verified JWT). If the bypass only affects UI reachability and the API already gate-keeps data, the risk is low — but it should be documented explicitly.
/credentials/metrics gating precision must be verified
D4 gates /credentials/metrics while keeping /credentials live. A naive startsWith('/credentials') check would incorrectly gate all of /credentials. The prefix list in soft-launch.ts must specifically list '/credentials/metrics' and the isGatedPath helper must handle prefix ordering correctly.
Redirect destination /bills/contratos should be confirmed auth-gated
All gated routes redirect to /bills/contratos. Confirm this destination is itself protected by Supabase Auth middleware so unauthenticated users hitting a gated route don't land on a public page.
conventions5
metrics/layout.tsx guard ordering: soft-launch redirect must precede ASSET_MANAGEMENT_HIDDEN notFound()
apps/platform/src/app/[locale]/(dashboard)/metrics/layout.tsx
The scope says to extend the existing ASSET_MANAGEMENT_HIDDEN guard. This produces two conditions: one calling notFound() and one calling redirect(). The soft-launch redirect must come first — otherwise a soft-launch user with ASSET_MANAGEMENT_HIDDEN=false bypasses the gate and reaches /metrics. Implementation plan should make this ordering explicit.
credentials/layout.tsx missing from file list — needed for D4 tab exclusion
apps/platform/src/app/[locale]/(dashboard)/credentials/layout.tsx
credentials/layout.tsx already conditionally omits the metrics tab based on ASSET_MANAGEMENT_HIDDEN. If D4 is accepted (gate /credentials/metrics), this file's tab-exclusion logic must be extended to also check the soft-launch condition. The scope's file list does not include credentials/layout.tsx.
assets/layout.tsx has a comment directing per-page guards — conflicts with the new layout guard
apps/platform/src/app/[locale]/(dashboard)/assets/layout.tsx:13
Existing comment says 'the per-page ASSETS_HIDDEN guard stays on each page'. Adding a soft-launch redirect in assets/layout.tsx creates two guard layers. The comment should be updated during implementation to clarify which layer owns what.
soft-launch.ts placement in lib/ is correct
apps/platform/src/lib/soft-launch.ts
Cross-route shared code consumed by DashboardSidebar.tsx and multiple segment layout.tsx files. lib/ (not _lib/) is correct — mirrors feature-flags.ts and routes.ts precedents.
isPlatformAdmin bypass mechanism for route guards is unspecified
DashboardSidebar.tsx already receives isPlatformAdmin as a prop. For layout.tsx guards (which don't receive props from parents in App Router), each guard must call useOrgContext() independently. The spec should document this mechanism explicitly so all 5 guard layouts are implemented consistently.
tests9
isGatedPath() unit tests not planned despite being the SSOT pure function
apps/platform/src/lib/soft-launch.ts
isGatedPath(pathname, { isPlatformAdmin }) is the single decision point governing both sidebar grey-out and route guards. As a pure function it runs at ~5000 tests/sec in Vitest. Required cases: exact prefix match (/sites), sub-path match (/sites/123), live paths (/bills/anything, /credentials), admin bypass, /credentials/metrics gated while /credentials is live (D4), /dashboard gated (D2), locale-prefixed paths (/es/sites). Without these, the SSOT's correctness is untested.
E2E auth.spec.ts post-login assertion will silently degrade after SG-3
e2e/platform/auth.spec.ts:20
Line 20 asserts toHaveURL(/(dashboard|bills)/) after login. SG-3 makes /dashboard gated. The regex still passes (matches /bills/contratos via the /bills arm) but silently allows a regression where /dashboard lands first and redirects — producing a double-hop that the test doesn't catch. Tighten to assert /bills/contratos as the final URL.
dashboard.spec.ts E2E navigates to /dashboard — will silently exercise the redirect after SG-2
e2e/platform/dashboard.spec.ts
DashboardPage.goto() navigates to /dashboard. After SG-2, this redirects to /bills/contratos. The test still passes (chrome renders on /bills/contratos) but is now silently testing the redirect, not the page. Update the spec to navigate to /bills/contratos directly or assert the redirect explicitly.
Admin bypass (D1) has no planned test path
The scope's success criteria and SG-1/SG-2 tests cover only the standard-user view. No test plan for: (a) admin sees Sites/Assets/Tarifas as active in the sidebar, (b) admin can navigate directly to /sites without redirect. These are distinct code paths that must be explicitly covered.
Route guard redirect vs notFound behavioral difference not tested
metrics/layout.tsx calls notFound() (404); soft-launch guards will call redirect(/bills/contratos) (302). The E2E suite should explicitly assert that navigating to /sites returns the user to /bills/contratos, not a 404 page.
getSafeRedirectUrl default change has no regression test
apps/platform/src/app/[locale]/(auth)/login/page.tsx
SG-3 changes the fallback from /dashboard to /bills/contratos. If getSafeRedirectUrl has existing unit tests, they need updating. The function's contract — sanitizes external URLs, handles ?redirect= param, falls back to live landing — should be unit-tested given it controls where every fresh login lands.
Sidebar test scope underspecified — partition test not defined
apps/platform/src/components/DashboardSidebar.tsx
SG-1 mentions 'sidebar test asserts the visible/disabled partition' with no specifics. Needed cases: (1) Bills + Credentials active, (2) Sites/Assets/Tarifas have disabled=true + Pronto badge, (3) Settings/Admin unaffected, (4) admin bypass shows active items. Also: Assets was previously hidden (removed from DOM) — snapshot tests may pass incorrectly after the hidden→disabled transition.
credentials/metrics prefix-collision edge case not in test plan
D4 gates /credentials/metrics while keeping /credentials live. A naive startsWith('/credentials') check would incorrectly gate all of /credentials. This prefix-collision must be an explicit unit test case for isGatedPath().
No E2E test for the gated redirect flow end-to-end
The main feature promise — 'direct navigation to /sites as an authenticated user redirects to /bills/contratos' — has no planned E2E test. One spec covering this flow would be the critical integration test. The e2e-testing.md pattern ('navigate to it directly rather than clicking the nav') already establishes the right approach.
improvement8
SG-3 misses three /dashboard entry-points: middleware, invite/[token], DashboardHeader logo
.branch/scope.md
router.push('/dashboard') appears 3 times in invite/[token]/page.tsx; DashboardHeader.tsx line ~105 does router.push('/{locale}/dashboard'); and the middleware proxy.ts redirects authenticated '/' to '/{locale}/dashboard'. All three will produce double-hops (redirect to /bills/contratos after SG-2) with visible chrome flashes. SG-3's grep step needs to cover these explicitly.
isGatedPath must handle locale-prefixed pathnames from usePathname()
apps/platform/src/lib/soft-launch.ts
usePathname() in client components returns locale-prefixed paths (e.g. '/es/sites/123'). Matching against '/sites' will silently fail. Either document that callers must strip the locale prefix first, or make isGatedPath locale-aware (strip the leading /{locale} segment before prefix matching). This is a runtime correctness risk that affects every guard.
Browser-back bypasses redirect guards — not in success criteria
.branch/scope.md
A cached back-navigation may restore the previous RSC payload without re-executing the layout guard. If the guard uses useEffect + router.replace() (like the dashboard layout auth check), it fires on every mount including back-nav — the safer pattern. The scope is silent on this. SG-2 should decide explicitly: useEffect + router.replace() vs redirect(), and whether back-nav is a supported escape hatch or a bug.
Shared <SoftLaunchGuard> should be decided in SG-1, not deferred to SG-2
.branch/scope.md:83
5 layout files need identical guard logic (sites, assets, tarifas, metrics, dashboard). Duplicating redirect + isPlatformAdmin check + SSOT read 5× creates 5 drift surfaces. The reuse-over-reinvention principle (P8) and App Router's no-parent-props constraint make a shared client component the only sensible answer. Decide this in SG-1 alongside the SSOT so SG-2 is purely mechanical placement.
DashboardHeader logo navigates to /dashboard — uncovered by SG-3
apps/platform/src/components/DashboardHeader.tsx:105
The logo click calls router.push('/{locale}/dashboard'). After soft-launch, clicking the logo produces a double-hop. SG-3's grep step should include this file. Clean fix: change the logo target to /{locale}/bills/contratos.
D2 ordering dependency: dashboard/layout.tsx guard (SG-2) should ship before the login default change (SG-3)
.branch/scope.md
The scope correctly lists SG-3 as depending on SG-2. Worth noting explicitly: the dashboard/layout.tsx guard is what makes the login-default change low-risk. Without it, a bookmarked /dashboard is still reachable after SG-3 lands.
dashboard/page.tsx already redirects to /bills — new dashboard/layout.tsx guard may be redundant
apps/platform/src/app/[locale]/(dashboard)/dashboard/page.tsx
Investigate whether the middleware and getSafeRedirectUrl actually target /{locale}/dashboard (the sub-segment) or the (dashboard) group root. If they target the group root (which has no page.tsx), the guard belongs on the group root layout, not a new dashboard/layout.tsx.
Rename SG-N to SG-4 for clarity
.branch/scope.md
SG-N reads as a placeholder. Naming it SG-4 makes the dependency chain explicit (depends on SG-1/2/3) and prevents implementers from skipping it as 'not a real sub-goal'.