Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,7 +28,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: npm

- run: npm ci
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 22 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 (
<ClerkProvider>
<ClerkProvider publishableKey={clerkPublishableKey}>
<html lang="en">
<body className={`${manrope.variable} min-h-screen font-sans`}>{children}</body>
</html>
Expand Down
4 changes: 4 additions & 0 deletions docs/PRODUCTION_ENV.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand All @@ -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`.
Expand Down
42 changes: 42 additions & 0 deletions docs/PRODUCTION_READINESS_GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
2 changes: 2 additions & 0 deletions lib/env.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'server-only';

import { buildNextPublicAppUrlErrorMessage, resolveConfiguredAppBaseUrl } from './app-url';
import { enforcePortfolioDemoGuardrail } from './portfolio-demo-guardrail';

type EnvSpec = {
name: string;
Expand Down Expand Up @@ -124,6 +125,7 @@ export function validateServerEnv() {
}

validateTwilioWebhookSecurityMode();
enforcePortfolioDemoGuardrail(process.env);
validateAppUrl();
validateDatabaseUrl();
validated = true;
Expand Down
34 changes: 34 additions & 0 deletions lib/portfolio-demo-guardrail.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = process.env) {
return env.NODE_ENV === 'production' || env.VERCEL_ENV === 'production';
}

export function isPortfolioDemoModeEnabled(env: Record<string, string | undefined> = process.env) {
return parseBooleanFlag(env.PORTFOLIO_DEMO_MODE);
}

export function isProductionDemoModeOverrideEnabled(env: Record<string, string | undefined> = process.env) {
return parseBooleanFlag(env.ALLOW_PRODUCTION_DEMO_MODE);
}

export function isPortfolioDemoModeBlockedInProduction(env: Record<string, string | undefined> = 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<string, string | undefined> = process.env) {
if (isPortfolioDemoModeBlockedInProduction(env)) {
throw new Error(getPortfolioDemoGuardrailErrorMessage());
}
}
28 changes: 27 additions & 1 deletion middleware.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down
9 changes: 9 additions & 0 deletions scripts/check_env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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());
Expand All @@ -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)');
Expand Down
39 changes: 39 additions & 0 deletions tests/portfolio-demo-guardrail.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});