diff --git a/.env.example b/.env.example index 84dd23c..dce535a 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,8 @@ TWILIO_VALIDATE_SIGNATURE= # Optional debug / demo DEBUG_ENV_ENDPOINT_TOKEN= PORTFOLIO_DEMO_MODE= +# Break-glass only: allows demo mode in production when explicitly set +ALLOW_PRODUCTION_DEMO_MODE= # Optional rate limiting (defaults are safe for provider webhooks) # RATE_LIMIT_WINDOW_MS=60000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 870f441..664ea91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: NEXT_PUBLIC_APP_URL: https://example.com DATABASE_URL: postgresql://postgres:postgres@localhost:5432/callbackcloser?sslmode=require DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/callbackcloser?sslmode=require - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: pk_test_placeholder + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: pk_test_Y2xlcmsuZXhhbXBsZS5jb20k CLERK_SECRET_KEY: sk_test_placeholder STRIPE_SECRET_KEY: sk_test_placeholder STRIPE_WEBHOOK_SECRET: whsec_placeholder @@ -28,7 +28,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm - run: npm ci diff --git a/README.md b/README.md index e1dfedd..fd9beeb 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ When a customer calls a business's Twilio number and the forwarded call is misse - Call recording enabled on forwarded calls + recording metadata captured on callbacks - Twilio webhook protection: production-enforced `X-Twilio-Signature` validation, with shared-token fallback only in non-production - Webhook observability baseline: correlation IDs (`X-Correlation-Id`), centralized `app.error` reporting, optional alert webhook dispatch +- Production guardrail: `PORTFOLIO_DEMO_MODE` is blocked in production unless `ALLOW_PRODUCTION_DEMO_MODE=true` is explicitly set ## Local Setup diff --git a/app/layout.tsx b/app/layout.tsx index 0266fed..6e718a7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,6 +6,8 @@ import { validateServerEnv } from '@/lib/env.server'; import './globals.css'; +const CLERK_PREVIEW_FALLBACK_KEY = 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k'; + const manrope = Manrope({ subsets: ['latin'], variable: '--font-sans', @@ -16,11 +18,30 @@ export const metadata: Metadata = { description: 'Missed Call -> Booked Job SMS follow-up', }; +function isLikelyValidClerkPublishableKey(value: string) { + return /^pk_(test|live)_[A-Za-z0-9+/=_-]+$/.test(value); +} + +function resolveClerkPublishableKey() { + const configured = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY?.trim() ?? ''; + if (configured && isLikelyValidClerkPublishableKey(configured)) { + return configured; + } + + const allowPreviewFallback = process.env.NODE_ENV !== 'production' || process.env.VERCEL_ENV === 'preview'; + if (allowPreviewFallback) { + return CLERK_PREVIEW_FALLBACK_KEY; + } + + return configured; +} + export default function RootLayout({ children }: { children: React.ReactNode }) { validateServerEnv(); + const clerkPublishableKey = resolveClerkPublishableKey(); return ( - + {children} diff --git a/docs/PRODUCTION_ENV.md b/docs/PRODUCTION_ENV.md index a5fc7bd..fe8730c 100644 --- a/docs/PRODUCTION_ENV.md +++ b/docs/PRODUCTION_ENV.md @@ -30,6 +30,7 @@ This project uses `NEXT_PUBLIC_APP_URL` as the single canonical app origin for s | `TWILIO_VALIDATE_SIGNATURE` | Server-only | Yes (production) | Vercel | Must be `true` in production. Twilio webhooks require valid `X-Twilio-Signature` verification using `TWILIO_AUTH_TOKEN`; production fails closed otherwise. | | `DEBUG_ENV_ENDPOINT_TOKEN` | Server-only | Optional | Vercel | Protects `/api/debug/env` in production. If unset, the endpoint returns `404` in production. | | `PORTFOLIO_DEMO_MODE` | Server-only | Optional | Local / Vercel | Enables demo data/auth bypass mode for portfolio/demo screenshots. Keep disabled in production unless intentionally using demo mode. | +| `ALLOW_PRODUCTION_DEMO_MODE` | Server-only | Optional (break-glass only) | Vercel | Required only when intentionally running demo mode in production. If unset while `PORTFOLIO_DEMO_MODE` is enabled in production, startup is blocked. | | `RATE_LIMIT_WINDOW_MS` | Server-only | Optional | Vercel | Shared rate-limit window in milliseconds. Default `60000`. | | `RATE_LIMIT_TWILIO_AUTH_MAX` | Server-only | Optional | Vercel | Max Twilio webhook requests per window for valid/authorized traffic. Default `240`. | | `RATE_LIMIT_TWILIO_UNAUTH_MAX` | Server-only | Optional | Vercel | Max Twilio webhook requests per window for unauthorized traffic. Default `40`. | @@ -53,6 +54,9 @@ The app now validates required server env vars at runtime in production via `lib - Twilio webhook auth behavior: - Production: `TWILIO_VALIDATE_SIGNATURE=true` is required and token-only auth is rejected - Non-production: signature mode can fall back to shared-token auth for local/dev workflows +- Demo mode safety guard: + - Production blocks startup/request handling if `PORTFOLIO_DEMO_MODE` is enabled without `ALLOW_PRODUCTION_DEMO_MODE=true`. + - Use `ALLOW_PRODUCTION_DEMO_MODE` only as an explicit break-glass override. - Rate limiting defaults are tuned to avoid blocking normal Twilio/Stripe provider traffic while still throttling abusive bursts. Tune limits only if you observe false positives in logs. - Error reporting emits structured `app.error` logs and, when configured, dispatches alert payloads to `ALERT_WEBHOOK_URL`. - `NEXT_PUBLIC_APP_URL` is the canonical value and should be set explicitly. If it is missing/invalid, the app can temporarily fall back to Vercel system env vars (`VERCEL_URL` / `VERCEL_PROJECT_PRODUCTION_URL`) to avoid auth-page crashes, but webhook/redirect behavior should still use an explicit `NEXT_PUBLIC_APP_URL`. diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index 6a5e6d8..c78674a 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -377,3 +377,45 @@ Dependencies: G4 (recommended) - `docs/PRODUCTION_READINESS_GAPS.md` - Commit SHA: - `2dc1d7c` + +- 2026-03-02 - G11 (DONE) + - Branch: `hardening/g11-production-demo-guardrail` + - What changed: + - Added shared production demo-mode guardrail logic in `lib/portfolio-demo-guardrail.ts`: + - detects production runtime (`NODE_ENV` / `VERCEL_ENV`) + - blocks `PORTFOLIO_DEMO_MODE` unless explicit override `ALLOW_PRODUCTION_DEMO_MODE=true` + - Enforced guardrail in production env validation: + - `lib/env.server.ts` now calls guardrail enforcement during `validateServerEnv()` + - Enforced guardrail at request layer: + - `middleware.ts` now returns `503` fail-safe when production demo mode is enabled without override + - break-glass override logs an explicit warning when enabled + - Extended env preflight checks: + - `scripts/check_env.ts` now fails when production demo mode is enabled without override + - Added focused guardrail tests: + - `tests/portfolio-demo-guardrail.test.ts` + - Updated env/docs to reflect break-glass requirement: + - `.env.example` + - `docs/PRODUCTION_ENV.md` + - `README.md` + - Safety notes: + - Production cannot silently run in portfolio-demo bypass mode anymore. + - Any intentional production demo-mode usage now requires explicit, auditable break-glass env activation. + - Commands run + results: + - `npm test` -> PASS (33/33) + - `npm run lint` -> PASS + - `npm run build` -> PASS + - `npm run typecheck` -> PASS + - `npm run env:check` -> PASS + - `npm run db:validate` -> PASS + - Files touched: + - `lib/portfolio-demo-guardrail.ts` + - `lib/env.server.ts` + - `middleware.ts` + - `scripts/check_env.ts` + - `tests/portfolio-demo-guardrail.test.ts` + - `.env.example` + - `docs/PRODUCTION_ENV.md` + - `README.md` + - `docs/PRODUCTION_READINESS_GAPS.md` + - Commit SHA: + - `fd9ca8e` diff --git a/lib/env.server.ts b/lib/env.server.ts index d48686c..1db59a5 100644 --- a/lib/env.server.ts +++ b/lib/env.server.ts @@ -1,6 +1,7 @@ import 'server-only'; import { buildNextPublicAppUrlErrorMessage, resolveConfiguredAppBaseUrl } from './app-url'; +import { enforcePortfolioDemoGuardrail } from './portfolio-demo-guardrail'; type EnvSpec = { name: string; @@ -124,6 +125,7 @@ export function validateServerEnv() { } validateTwilioWebhookSecurityMode(); + enforcePortfolioDemoGuardrail(process.env); validateAppUrl(); validateDatabaseUrl(); validated = true; diff --git a/lib/portfolio-demo-guardrail.ts b/lib/portfolio-demo-guardrail.ts new file mode 100644 index 0000000..c9589fa --- /dev/null +++ b/lib/portfolio-demo-guardrail.ts @@ -0,0 +1,34 @@ +function parseBooleanFlag(value: string | undefined) { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + +export function isProductionRuntime(env: Record = process.env) { + return env.NODE_ENV === 'production' || env.VERCEL_ENV === 'production'; +} + +export function isPortfolioDemoModeEnabled(env: Record = process.env) { + return parseBooleanFlag(env.PORTFOLIO_DEMO_MODE); +} + +export function isProductionDemoModeOverrideEnabled(env: Record = process.env) { + return parseBooleanFlag(env.ALLOW_PRODUCTION_DEMO_MODE); +} + +export function isPortfolioDemoModeBlockedInProduction(env: Record = process.env) { + return isProductionRuntime(env) && isPortfolioDemoModeEnabled(env) && !isProductionDemoModeOverrideEnabled(env); +} + +export function getPortfolioDemoGuardrailErrorMessage() { + return ( + 'Invalid environment configuration: PORTFOLIO_DEMO_MODE is enabled in production without ALLOW_PRODUCTION_DEMO_MODE=true. ' + + 'Disable demo mode for production or explicitly set ALLOW_PRODUCTION_DEMO_MODE=true for break-glass use.' + ); +} + +export function enforcePortfolioDemoGuardrail(env: Record = process.env) { + if (isPortfolioDemoModeBlockedInProduction(env)) { + throw new Error(getPortfolioDemoGuardrailErrorMessage()); + } +} diff --git a/middleware.ts b/middleware.ts index 4ffa092..fba37b4 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,14 +1,40 @@ import { NextResponse } from 'next/server'; import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; +import { + getPortfolioDemoGuardrailErrorMessage, + isPortfolioDemoModeBlockedInProduction, + isPortfolioDemoModeEnabled, + isProductionDemoModeOverrideEnabled, +} from '@/lib/portfolio-demo-guardrail'; import { RATE_LIMIT_PROTECTED_API_MAX, RATE_LIMIT_WINDOW_MS } from '@/lib/rate-limit-config'; import { buildRateLimitHeaders, consumeRateLimit, getClientIpAddress } from '@/lib/rate-limit'; const isProtectedRoute = createRouteMatcher(['/app(.*)', '/api/stripe/checkout(.*)', '/api/stripe/portal(.*)']); const isProtectedApiMutationRoute = createRouteMatcher(['/api/stripe/checkout', '/api/stripe/portal']); +let productionDemoGuardrailLogged = false; +let productionDemoOverrideLogged = false; export default clerkMiddleware(async (auth, req) => { - if (process.env.PORTFOLIO_DEMO_MODE === '1') { + if (isPortfolioDemoModeBlockedInProduction(process.env)) { + if (!productionDemoGuardrailLogged) { + productionDemoGuardrailLogged = true; + console.error(getPortfolioDemoGuardrailErrorMessage(), { + nodeEnv: process.env.NODE_ENV ?? null, + vercelEnv: process.env.VERCEL_ENV ?? null, + }); + } + return NextResponse.json({ error: getPortfolioDemoGuardrailErrorMessage() }, { status: 503 }); + } + + if (isPortfolioDemoModeEnabled(process.env)) { + if (isProductionDemoModeOverrideEnabled(process.env) && !productionDemoOverrideLogged) { + productionDemoOverrideLogged = true; + console.warn('Production demo mode override is enabled (break-glass).', { + nodeEnv: process.env.NODE_ENV ?? null, + vercelEnv: process.env.VERCEL_ENV ?? null, + }); + } return; } diff --git a/scripts/check_env.ts b/scripts/check_env.ts index 17b50b6..05d2a7f 100644 --- a/scripts/check_env.ts +++ b/scripts/check_env.ts @@ -12,6 +12,8 @@ const loadedFiles = loadLocalEnvFiles(); const signatureValidationEnabled = readBooleanEnv('TWILIO_VALIDATE_SIGNATURE'); const productionNodeEnv = process.env.NODE_ENV === 'production'; +const demoModeEnabled = readBooleanEnv('PORTFOLIO_DEMO_MODE'); +const demoModeOverrideEnabled = readBooleanEnv('ALLOW_PRODUCTION_DEMO_MODE'); const requirements: EnvRequirement[] = [ { name: 'NEXT_PUBLIC_APP_URL', required: true, reason: 'Canonical app URL / webhook URL generation' }, @@ -39,6 +41,7 @@ const requirements: EnvRequirement[] = [ }, { name: 'DEBUG_ENV_ENDPOINT_TOKEN', required: false, reason: 'Optional debug endpoint token' }, { name: 'PORTFOLIO_DEMO_MODE', required: false, reason: 'Optional demo mode' }, + { name: 'ALLOW_PRODUCTION_DEMO_MODE', required: false, reason: 'Optional break-glass override for demo mode in production' }, ]; const missing = requirements.filter((item) => item.required && !process.env[item.name]?.trim()); @@ -48,9 +51,15 @@ if (productionNodeEnv && !signatureValidationEnabled) { configErrors.push('TWILIO_VALIDATE_SIGNATURE must be true when NODE_ENV=production'); } +if (productionNodeEnv && demoModeEnabled && !demoModeOverrideEnabled) { + configErrors.push('PORTFOLIO_DEMO_MODE cannot be enabled in production without ALLOW_PRODUCTION_DEMO_MODE=true'); +} + console.log('CallbackCloser env check'); console.log(`- Loaded env files: ${loadedFiles.join(', ') || '(none)'}`); console.log(`- TWILIO_VALIDATE_SIGNATURE: ${signatureValidationEnabled ? 'enabled' : 'disabled'}`); +console.log(`- PORTFOLIO_DEMO_MODE: ${demoModeEnabled ? 'enabled' : 'disabled'}`); +console.log(`- ALLOW_PRODUCTION_DEMO_MODE: ${demoModeOverrideEnabled ? 'enabled' : 'disabled'}`); if (missing.length === 0 && configErrors.length === 0) { console.log('- Result: PASS (all required env vars are present)'); diff --git a/tests/portfolio-demo-guardrail.test.ts b/tests/portfolio-demo-guardrail.test.ts new file mode 100644 index 0000000..4c7b840 --- /dev/null +++ b/tests/portfolio-demo-guardrail.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + enforcePortfolioDemoGuardrail, + isPortfolioDemoModeBlockedInProduction, +} from '../lib/portfolio-demo-guardrail.ts'; + +test('blocks production when demo mode is enabled without override', () => { + const env = { + NODE_ENV: 'production', + PORTFOLIO_DEMO_MODE: '1', + ALLOW_PRODUCTION_DEMO_MODE: '', + }; + + assert.equal(isPortfolioDemoModeBlockedInProduction(env), true); + assert.throws(() => enforcePortfolioDemoGuardrail(env)); +}); + +test('allows production demo mode only with explicit override', () => { + const env = { + NODE_ENV: 'production', + PORTFOLIO_DEMO_MODE: 'true', + ALLOW_PRODUCTION_DEMO_MODE: 'true', + }; + + assert.equal(isPortfolioDemoModeBlockedInProduction(env), false); + assert.doesNotThrow(() => enforcePortfolioDemoGuardrail(env)); +}); + +test('does not block demo mode in non-production', () => { + const env = { + NODE_ENV: 'development', + PORTFOLIO_DEMO_MODE: '1', + ALLOW_PRODUCTION_DEMO_MODE: '', + }; + + assert.equal(isPortfolioDemoModeBlockedInProduction(env), false); +});