Skip to content

Latest commit

 

History

History
212 lines (185 loc) · 12.9 KB

File metadata and controls

212 lines (185 loc) · 12.9 KB

Example: elevate report (web, abbreviated)

This example shows two dimensions, the First move section, and the Dropped-during-verification section in full to demonstrate shape, tone, grounding, and the verify pass for a web app. A real report contains all seven dimensions with up to 5 items each.

The "project" here is a fictional B2B customer-analytics dashboard called Cohort — Next.js 14 App Router, React Server Components, Postgres via Prisma, Stripe for billing, deployed on Vercel.

The dimensions covered here (Reliability, Security) are different from the iOS example so a reader sees the platform-specific lenses in action.


Elevate Plan: Cohort

Platform: web (inferred — package.json lists next@14.2, react@18.3, @prisma/client; next.config.mjs present) Surveyed: 2026-05-06 Coverage: full — app/, lib/, server actions, middleware, env config, dependency manifests Attractiveness anchor: Linear (user-supplied)

Reliability

1. Add timeout and retry to the analytics API client

  • Location: lib/api/client.ts:34
  • Proof:
    // lib/api/client.ts:32-40 — quote covers entire fetcher; no signal, no retry wrapper.
    export async function fetchAnalytics<T>(path: string, init?: RequestInit): Promise<T> {
        const res = await fetch(`${BASE_URL}${path}`, {
            ...init,
            headers: { ...defaultHeaders, ...init?.headers },
        });
        if (!res.ok) throw new Error(`Analytics ${res.status}`);
        return res.json();
    }
  • Verified: grep -n "AbortSignal\|AbortController\|withRetry\|attempts" lib/api/client.ts# (no matches)
  • Do: Wrap in a withRetry(fn, { attempts: 3, backoff: 'exponential' }) helper and add a 10-second AbortSignal.timeout(10_000). Retry on 502/503/504 and network errors only — not on 4xx.
  • Why: Vercel logs show ~2% of analytics requests time out at 30 s waiting for a response that already failed upstream. Users see a hung loading state instead of a recoverable error.
  • Effort: S · Impact: M

2. Make Stripe webhook ingestion idempotent

  • Location: app/api/webhooks/stripe/route.ts:18
  • Proof:
    // route.ts:16-26 — quote covers entire POST handler; no event_id lookup.
    export async function POST(req: Request) {
        const sig = req.headers.get('stripe-signature')!;
        const body = await req.text();
        const event = stripe.webhooks.constructEvent(body, sig, WEBHOOK_SECRET);
        await processEvent(event);                  // ← direct dispatch, no dedupe
        return Response.json({ received: true });
    }
  • Verified: grep -rn "webhook_events\|event_id\|onConflict.*event" app/api/webhooks/ prisma/schema.prisma# (no matches)
  • Do: Add a webhook_events table with a unique constraint on (provider, event_id) and short-circuit duplicates with 200 OK before any side effects.
  • Why: Stripe retries webhooks for up to 3 days. Without idempotency a delayed retry can double-credit a customer or re-fire downstream emails — both real incidents this quarter.
  • Effort: S · Impact: L

3. Cache the customer-segments query with explicit invalidation

  • Location: app/(dashboard)/segments/page.tsx:12
  • Proof:
    // page.tsx:10-18 — quote covers the data fetch; no cache wrapper.
    export default async function SegmentsPage({ searchParams }) {
        const segments = await prisma.segment.findMany({  // ← runs every request
            where: { orgId: getOrgId() },
            orderBy: { updatedAt: 'desc' },
        });
        return <SegmentsTable segments={segments} />;
    }
  • Verified: grep -rn "unstable_cache\|revalidateTag" app/(dashboard)/segments/ app/actions/segments.ts# (no matches)
  • Do: Wrap with unstable_cache keyed by (orgId, filterHash) with a 5-minute TTL, and invalidate via revalidateTag('segments') from the four mutation paths in app/actions/segments.ts.
  • Why: Cold visits feel slow (~800 ms p50) and the upstream Postgres query runs harder than needed. Cache invalidation is well-defined here (only four mutation paths), so this won't drift.
  • Effort: M · Impact: M

4. Handle partial failure on bulk CSV import

  • Location: app/actions/import.ts:47
  • Proof:
    // import.ts:45-55 — quote covers entire import action; one big transaction.
    export async function importCsv(file: File) {
        const rows = await parseCsv(file);
        await prisma.$transaction(                    // ← all-or-nothing
            rows.map(r => prisma.customer.create({ data: r }))
        );
        return { ok: true };
    }
  • Verified: grep -n "skippedRows\|errorCsv\|per-row" app/actions/import.ts components/Import*# (no matches)
  • Do: Switch to row-level transactions, return a per-row result map, and surface a "1,234 imported, 12 skipped" summary plus a downloadable error CSV.
  • Why: Users with messy spreadsheets currently can't import anything until every row is perfect. #1 support ticket and the Reliability gap that costs the most onboarding revenue.
  • Effort: M · Impact: L

5. Version the React Query cache key namespace

  • Location: lib/query/keys.ts:1
  • Proof:
    // keys.ts:1-9 — quote covers the entire export; no version prefix.
    export const queryKeys = {
        segments: (orgId: string) => ['segments', orgId] as const,
        customers: (orgId: string, q?: string) => ['customers', orgId, q] as const,
        events: (id: string) => ['events', id] as const,
    };
  • Verified: grep -n "__v\|CACHE_VERSION\|persistVersion" lib/query/# (no matches)
  • Do: Prefix every key with a single __v1_ constant and bump on shape changes.
  • Why: Two production bugs in the last quarter traced to this. One-line change that closes the entire class.
  • Effort: S · Impact: M

Security

1. Move the Postgres connection string out of NEXT_PUBLIC_*

  • Location: lib/db.ts:7
  • Proof:
    // lib/db.ts:5-11 — quote covers the client construction.
    import { PrismaClient } from '@prisma/client';
    export const prisma = new PrismaClient({
        datasources: {
            db: { url: process.env.NEXT_PUBLIC_DATABASE_URL! },  // ← bundles to client
        },
    });
  • Verified: next build && grep -c "DATABASE_URL" .next/static/chunks/*.js12 (credential leaks into 12 client chunks)
  • Do: Rename to DATABASE_URL, ensure the import path runs server-side only (move into lib/server/db.ts and re-export with import 'server-only' at the top), and verify the bundle-grep returns 0 post-fix.
  • Why: This is a credential leak. The DB is currently behind IP allowlisting that masks the impact, but security depth-in-defense should never depend on a single layer that wasn't designed as the last line.
  • Effort: S · Impact: L

2. Adopt a strict Content Security Policy

  • Location: next.config.mjs and a new middleware.ts
  • Proof:
    // next.config.mjs (entire file) — no headers() function, no CSP.
    /** @type {import('next').NextConfig} */
    const nextConfig = { reactStrictMode: true, experimental: { ppr: true } };
    export default nextConfig;
  • Verified: grep -rn "Content-Security-Policy\|nonce" next.config.mjs middleware.ts app/ 2>/dev/null → # (no matches; middleware.ts does not exist)
  • Do: Start with a report-only policy (default-src 'self'; script-src 'self' 'nonce-...' https://js.stripe.com; report-uri /api/csp) and enforce after two weeks of clean reports.
  • Why: Mitigates an entire class of XSS that the team has been lucky to avoid. Free win for a SaaS app handling customer data.
  • Effort: M · Impact: M

3. Move auth tokens from localStorage to HttpOnly cookies

  • Location: lib/auth/storage.ts:14
  • Proof:
    // lib/auth/storage.ts:12-20 — JWT stored client-side, readable from any script.
    export function setSession(token: string) {
        localStorage.setItem(SESSION_KEY, token);    // ← reachable from any JS
    }
    export function getSession() {
        return localStorage.getItem(SESSION_KEY);
    }
  • Verified: grep -rn "Set-Cookie\|cookies()\|HttpOnly" app/actions/auth.ts lib/auth/# (no matches)
  • Do: Switch to HttpOnly, Secure, SameSite=Lax cookies set by the server-side login action via Next's cookies() helper; rework the API client to omit the bearer header and rely on cookies.
  • Why: localStorage is reachable from any script that runs in the page; an XSS in any third-party widget steals every session. Cookies move the secret out of JS reach.
  • Effort: M · Impact: L

4. Triage pnpm audit and gate it in CI

  • Location: package.json, .github/workflows/ci.yml
  • Proof:
    // `pnpm audit --prod --audit-level=high` output (excerpt):
    18 vulnerabilities (12 moderate, 3 high, 3 low) found
     high: cookie@<0.7.0 (CVE-2024-47764)
     high: ws@>=8.0.0 <8.17.1 (CVE-2024-37890)
     high: serialize-javascript@<6.0.2 (CVE-2024-11831)
    
  • Verified: grep -n "audit" .github/workflows/ci.yml# (no matches; no audit gate in CI)
  • Do: Drop unused packages where possible, pin or override unavoidable transitive deps via pnpm.overrides, and add pnpm audit --prod --audit-level=high as a CI gate.
  • Why: Two of the three highs are in active dependency trees. The CI gate prevents new ones from sliding in unnoticed during the next dependency bump.
  • Effort: S · Impact: M

5. Lock down the /admin route group at the edge

  • Location: app/(admin)/layout.tsx:8
  • Proof:
    // (admin)/layout.tsx:6-14 — auth check happens AFTER the bundle ships.
    export default async function AdminLayout({ children }) {
        const user = await getCurrentUser();
        if (!user?.isAdmin) redirect('/');           // ← runs client-side after JS loads
        return <AdminShell>{children}</AdminShell>;
    }
  • Verified: grep -n "matcher\|/admin" middleware.ts 2>/dev/null# (no middleware.ts file at repo root)
  • Do: Add an edge middleware check (middleware.ts with matcher: ['/admin/:path*']) that returns 404 before the bundle is served, so the admin chunks are never downloaded by non-admin users.
  • Why: Reduces leaked surface area. Today, code-splitting hides the worst of it, but the admin chunks remain discoverable via a network-tab pull — and they reveal feature names the team hasn't announced.
  • Effort: S · Impact: M

(Performance, Functionality, Stability, Usability, Attractiveness/Sexiness — each up to 5 items in a real report, following the same per-item shape above.)


First move

Move the Postgres connection string out of NEXT_PUBLIC_* (Security)

Of the items here, this is the only one that is a credential leak in production. The IP allowlist masks the impact today, but security depth-in-defense should never depend on a single layer that wasn't designed as the last line — if the allowlist is widened (for a contractor, a partner, a CI runner) the leak becomes immediately exploitable. The fix is one rename, one import-path adjustment, and a bundle-grep verification. Under a day's work, removes a class of "we got lucky" from the threat model, and the team can ship it on the next deploy without coordinating with anyone outside engineering.

Dropped during verification

  • "Add CSRF protection to server actions" — anti-existence grep grep -rn "next-csrf\|getCsrfToken\|originValidation" app/ lib/ returned hits from lib/auth/csrf.ts. Next.js server actions already enforce same-origin via the framework's built-in origin check; manual CSRF is redundant.
  • "Use parameterized queries to prevent SQL injection" — every DB call goes through prisma.* which parameterizes by default; no raw prisma.$queryRaw calls in the repo (grep -rn "\\\$queryRaw\\|raw(" app/ lib/ returned # (no matches)). Category error.
  • "Enable HTTP/2 server push for critical CSS" — HTTP/2 push has been deprecated by every major browser (Chrome removed it in M106); the proposed fix is for a feature that no longer exists. Not actionable.
  • "Move bcrypt to argon2 for password hashing" — Citation failed: grep -rn "bcrypt\|argon2" lib/auth/ returned # (no matches). Cohort uses a third-party auth provider (Auth0) for credentials; the app doesn't hash passwords itself, so the proposed migration doesn't apply.
  • "Add helmet.js for security headers"helmet is an Express middleware; this is a Next.js App Router app where security headers are set via next.config.mjs headers() (covered by SEC #2 above). Wrong tool for the framework.

Deferred

  • "Add an open-source observability pipeline" — observability is a real gap, but didn't land in any one dimension's top 5; opening as a separate spike rather than padding here.
  • "Migrate Pages Router fragments to App Router" — partially complete; the audit reflects current state, not in-flight work.
  • "Add SOC 2 Type 1 evidence collection" — process work, not codebase work; out of scope for an elevate audit.