From 7833bdcb6423ef39cf6cb503efaf7d1d7320d084 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Mon, 25 May 2026 14:26:43 -0400 Subject: [PATCH] Harden public routing and production security --- .env.example | 5 + app/(auth)/sign-in/[[...sign-in]]/page.tsx | 35 +++++- app/(auth)/sign-up/[[...sign-up]]/page.tsx | 60 ++++++++- app/admin/page.tsx | 6 + app/api/twilio/provision-number/route.ts | 6 + app/contact/page.tsx | 9 +- app/demo/page.tsx | 6 +- app/page.tsx | 15 ++- app/pricing/page.tsx | 8 +- app/simulator/actions.ts | 2 +- app/simulator/page.tsx | 15 +-- app/start-free-pilot/page.tsx | 27 ++++ components/public-site-footer.tsx | 5 + components/public-site-nav.tsx | 14 ++- lib/log-sanitization.ts | 73 +++++++++++ lib/observability.ts | 4 +- lib/public-auth-routing.ts | 35 ++++++ lib/security-headers.ts | 20 +++ lib/twilio-logging.ts | 3 +- middleware.ts | 14 ++- tests/admin-auth.test.ts | 26 ++++ tests/legal-pages.test.ts | 11 ++ tests/log-sanitization.test.ts | 47 +++++++ tests/public-auth-routing.test.ts | 62 ++++++++++ tests/public-demo-route.test.ts | 7 ++ tests/security-headers.test.ts | 5 + tests/tenant-isolation-wiring.test.ts | 15 +++ tests/webhook-security.test.ts | 137 +++++++++++++++++++++ 28 files changed, 638 insertions(+), 34 deletions(-) create mode 100644 app/start-free-pilot/page.tsx create mode 100644 lib/log-sanitization.ts create mode 100644 lib/public-auth-routing.ts create mode 100644 tests/log-sanitization.test.ts create mode 100644 tests/webhook-security.test.ts diff --git a/.env.example b/.env.example index 37a5c27..be8a744 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ # App +# Required in production. Use your deployed app origin, e.g. https://callbackcloser.com NEXT_PUBLIC_APP_URL= # Database (Postgres) @@ -6,13 +7,16 @@ DATABASE_URL= DIRECT_DATABASE_URL= # Clerk +# Required in production NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= CLERK_SECRET_KEY= NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up +# Optional comma-separated admin emails allowed into /admin ADMIN_EMAIL_ALLOWLIST= # Stripe +# Required in production NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= @@ -43,6 +47,7 @@ RESEND_API_KEY= CALLBACKCLOSER_FROM_EMAIL= # Optional public simulator +# Keep these disabled in production unless the public simulator is intentionally enabled. ENABLE_PUBLIC_MISSED_CALL_SIMULATOR= SIMULATOR_BUSINESS_ID= ENABLE_PUBLIC_SIMULATOR_REAL_SMS= diff --git a/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/app/(auth)/sign-in/[[...sign-in]]/page.tsx index 4a6d964..24f988b 100644 --- a/app/(auth)/sign-in/[[...sign-in]]/page.tsx +++ b/app/(auth)/sign-in/[[...sign-in]]/page.tsx @@ -1,13 +1,42 @@ +import { auth } from '@clerk/nextjs/server'; import { SignIn } from '@clerk/nextjs'; +import { redirect } from 'next/navigation'; +import { getAdminSession } from '@/lib/admin'; +import { getBusinessForOwnerClerkId } from '@/lib/business-access'; import { DEFAULT_CLERK_AFTER_AUTH_URL, getClerkAuthUrls } from '@/lib/clerk-config'; +import { resolveSignedInAppDestination } from '@/lib/public-auth-routing'; + +export default async function SignInPage() { + const { userId } = await auth(); + if (userId) { + const [adminSession, business] = await Promise.all([ + getAdminSession(), + getBusinessForOwnerClerkId(userId), + ]); + + redirect( + resolveSignedInAppDestination({ + isAdmin: Boolean(adminSession?.isAdmin), + hasBusiness: Boolean(business), + }) + ); + } -export default function SignInPage() { const { signInUrl, signUpUrl } = getClerkAuthUrls(); return ( -
- +
+
+

Existing users

+

Sign in to your CallbackCloser workspace

+

+ Sign in is for existing owners and operators. If you need a new business workspace, create an account or start a free pilot from the public site instead. +

+
+
+ +
); } diff --git a/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/app/(auth)/sign-up/[[...sign-up]]/page.tsx index 7bead95..08fd0af 100644 --- a/app/(auth)/sign-up/[[...sign-up]]/page.tsx +++ b/app/(auth)/sign-up/[[...sign-up]]/page.tsx @@ -1,13 +1,67 @@ +import { auth } from '@clerk/nextjs/server'; import { SignUp } from '@clerk/nextjs'; +import { redirect } from 'next/navigation'; +import { getAdminSession } from '@/lib/admin'; +import { getBusinessForOwnerClerkId } from '@/lib/business-access'; import { DEFAULT_CLERK_AFTER_AUTH_URL, getClerkAuthUrls } from '@/lib/clerk-config'; +import { resolveSignedInAppDestination } from '@/lib/public-auth-routing'; + +function getIntentCopy(intent: string | undefined) { + if (intent === 'pilot') { + return { + label: 'Start Free Pilot', + title: 'Create your account and start pilot onboarding', + detail: + 'This path is for a business owner creating a new CallbackCloser account. If you are already signed in, CallbackCloser will send you to onboarding, your dashboard, or the admin new-business flow based on your role.', + }; + } + + return { + label: 'Create Account', + title: 'Create your CallbackCloser account', + detail: + 'Create a new owner account here. Founder-operated customer pilot setup is separate and stays inside the admin new-business flow.', + }; +} + +export default async function SignUpPage({ + searchParams, +}: { + searchParams?: Record; +}) { + const { userId } = await auth(); + if (userId) { + const [adminSession, business] = await Promise.all([ + getAdminSession(), + getBusinessForOwnerClerkId(userId), + ]); + + redirect( + resolveSignedInAppDestination({ + isAdmin: Boolean(adminSession?.isAdmin), + hasBusiness: Boolean(business), + }) + ); + } -export default function SignUpPage() { const { signInUrl, signUpUrl } = getClerkAuthUrls(); + const intent = typeof searchParams?.intent === 'string' ? searchParams.intent : undefined; + const copy = getIntentCopy(intent); return ( -
- +
+
+

{copy.label}

+

{copy.title}

+

{copy.detail}

+

+ Existing users should sign in. CallbackCloser operators setting up a customer pilot should use the admin new-business flow, not public signup. +

+
+
+ +
); } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index a8b8457..f7137da 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -128,6 +128,7 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor const admin = await requireAdmin(); const createdDemo = getQueryValue(searchParams, 'createdDemo') === '1'; const createdBusinessId = getQueryValue(searchParams, 'createdBusinessId'); + const setupIntent = getQueryValue(searchParams, 'intent'); const selectedBusinessId = getQueryValue(searchParams, 'businessId'); const archived = getQueryValue(searchParams, 'archived') === '1'; const deleted = getQueryValue(searchParams, 'deleted') === '1'; @@ -528,6 +529,11 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor support workspace below. ) : null} + {setupIntent === 'new-business-pilot' ? ( +
+ Customer pilot setup starts here. Public signup is for business owners creating their own account; founder/operator setup for a real customer stays in this admin new-business flow. +
+ ) : null}
diff --git a/app/api/twilio/provision-number/route.ts b/app/api/twilio/provision-number/route.ts index c435a91..7220fa1 100644 --- a/app/api/twilio/provision-number/route.ts +++ b/app/api/twilio/provision-number/route.ts @@ -2,7 +2,9 @@ import { auth } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; +import { getConfiguredAppBaseUrl } from '@/lib/env.server'; import { getCorrelationIdFromRequest, withCorrelationIdHeader } from '@/lib/observability'; +import { isAllowedRequestOrigin } from '@/lib/request-origin'; import { logTwilioError, logTwilioWarn } from '@/lib/twilio-logging'; import { getTwilioProvisioningBlockReason, provisionPhoneNumber } from '@/lib/twilio-provision'; @@ -35,6 +37,10 @@ export async function POST(request: Request) { const correlationId = getCorrelationIdFromRequest(request); const withCorrelation = (response: NextResponse) => withCorrelationIdHeader(response, correlationId); + if (process.env.NODE_ENV === 'production' && !isAllowedRequestOrigin(request, getConfiguredAppBaseUrl())) { + return withCorrelation(NextResponse.json({ error: 'Invalid request origin' }, { status: 403 })); + } + const { userId } = await auth(); if (!userId) { return withCorrelation(NextResponse.json({ error: 'Unauthorized' }, { status: 401 })); diff --git a/app/contact/page.tsx b/app/contact/page.tsx index 3c5e94b..ba165d9 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -4,6 +4,7 @@ import { PublicSiteFooter } from '@/components/public-site-footer'; import { PublicSiteNav } from '@/components/public-site-nav'; import { buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing'; const outreachInputs = [ 'Business name and service type', @@ -45,11 +46,15 @@ export default function ContactPage() { For SMS consent, STOP or HELP behavior, or public trust-page questions, email support and reference{' '} callbackcloser.com so we can match the request to the live pilot setup.

+

Founder/operator customer pilot setup is separate from public signup and stays inside the admin new-business flow.

- + Start pilot onboarding - + + Create account + + View pricing diff --git a/app/demo/page.tsx b/app/demo/page.tsx index 59a1f82..2e692f6 100644 --- a/app/demo/page.tsx +++ b/app/demo/page.tsx @@ -16,6 +16,7 @@ import { demoTrustPoints, demoWorkflowSteps, } from '@/lib/demo-data'; +import { PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing'; import { cn } from '@/lib/utils'; export const metadata: Metadata = { @@ -61,6 +62,7 @@ export default function DemoPage() {

Get this live on your number fast, without changing how your team already works.

+

Demo only: fake business, fake callers, and no live customer or Twilio data.

@@ -221,7 +223,7 @@ export default function DemoPage() { Want this on your business? - + See it on your number
@@ -261,7 +263,7 @@ export default function DemoPage() {

- + See it on your number diff --git a/app/page.tsx b/app/page.tsx index c752e1e..770f652 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,6 +5,7 @@ import { PublicSiteNav } from '@/components/public-site-nav'; import { Badge } from '@/components/ui/badge'; import { buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing'; const roiPoints = [ { @@ -145,14 +146,14 @@ export default function LandingPage() {

- + Start Free Pilot - - See Demo + + Create Account - - Pricing + + See Demo
@@ -162,6 +163,10 @@ export default function LandingPage() { Less admin chasing

Close one extra job and this can pay for itself.

+

+ Start Free Pilot creates a new account or takes an existing user to the right next step automatically. + Founder-operated customer pilot setup stays separate in the admin new-business flow. +

diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index d5a5291..ff85ca8 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -5,6 +5,7 @@ import { PublicSiteNav } from '@/components/public-site-nav'; import { Badge } from '@/components/ui/badge'; import { buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing'; const pricingPlans = [ { @@ -66,7 +67,7 @@ export default function PricingPage() { support@callbackcloser.com - . + . Founder-run customer pilot setup is separate from public signup.

@@ -87,7 +88,7 @@ export default function PricingPage() { Contact Sales ) : ( - + Start Free Pilot )} @@ -122,6 +123,9 @@ export default function PricingPage() {

STOP, START, and HELP handling remain part of the live messaging flow, and the consent page stays public.

Businesses remain responsible for lawful texting practices and consent requirements in their market.

+ + Create account + Review SMS consent diff --git a/app/simulator/actions.ts b/app/simulator/actions.ts index 9d1a048..9a77213 100644 --- a/app/simulator/actions.ts +++ b/app/simulator/actions.ts @@ -38,7 +38,7 @@ export async function startSimulatorRunAction(formData: FormData) { const transport = realSmsEnabled ? 'twilio' : 'simulated'; const notice = realSmsEnabled ? 'Simulator started. CallbackCloser should text the number you entered from the demo business texting line.' - : 'Preview mode active. This simulator run updates the transcript and owner alerts on-page, but it does not text your phone until a real demo texting line is assigned and ENABLE_PUBLIC_SIMULATOR_REAL_SMS=true.'; + : 'Preview mode active. This simulator run updates the transcript and owner alerts on-page, but it does not text your phone until the demo texting line is configured for real SMS delivery.'; const call = await db.call.create({ data: { diff --git a/app/simulator/page.tsx b/app/simulator/page.tsx index 98c7e99..0e788e7 100644 --- a/app/simulator/page.tsx +++ b/app/simulator/page.tsx @@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { formatDateTime, getLeadStatusBadgeVariant, leadReadinessLabels, leadStatusLabels } from '@/lib/lead-presenters'; +import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing'; import { canSendRealSimulatorSms, getSimulatorBusiness, getSimulatorRun, isPlaceholderSimulatorNumber, isPublicSimulatorEnabled } from '@/lib/simulator'; export const metadata: Metadata = { @@ -101,7 +102,7 @@ export default async function SimulatorPage({ Demo number unavailable )} - + Start Free Pilot
@@ -115,7 +116,7 @@ export default async function SimulatorPage({ ? 'CallbackCloser will text the phone number you enter from the demo business texting line, then continue the rest of the flow in this page.' : usingPlaceholderTextingLine ? 'This demo workspace is still using the safe placeholder texting line, so CallbackCloser will show the recovery text and owner alerts on this page instead of sending a real SMS.' - : 'ENABLE_PUBLIC_SIMULATOR_REAL_SMS is still off, so CallbackCloser will show the recovery text and owner alerts on this page instead of sending a real SMS.'} + : 'Real SMS mode is not enabled for this environment yet, so CallbackCloser will show the recovery text and owner alerts on this page instead of sending a real SMS.'}

@@ -258,14 +259,14 @@ export default async function SimulatorPage({ )}
- - Start trial + + Create account - - Request setup + + Start Free Pilot - Book demo + View pricing
diff --git a/app/start-free-pilot/page.tsx b/app/start-free-pilot/page.tsx new file mode 100644 index 0000000..e8a8f82 --- /dev/null +++ b/app/start-free-pilot/page.tsx @@ -0,0 +1,27 @@ +import { auth } from '@clerk/nextjs/server'; +import { redirect } from 'next/navigation'; + +import { getAdminSession } from '@/lib/admin'; +import { getBusinessForOwnerClerkId } from '@/lib/business-access'; +import { resolvePublicPilotDestination } from '@/lib/public-auth-routing'; + +export default async function StartFreePilotPage() { + const { userId } = await auth(); + + if (!userId) { + redirect(resolvePublicPilotDestination({ isAuthenticated: false, isAdmin: false, hasBusiness: false })); + } + + const [adminSession, business] = await Promise.all([ + getAdminSession(), + getBusinessForOwnerClerkId(userId), + ]); + + redirect( + resolvePublicPilotDestination({ + isAuthenticated: true, + isAdmin: Boolean(adminSession?.isAdmin), + hasBusiness: Boolean(business), + }) + ); +} diff --git a/components/public-site-footer.tsx b/components/public-site-footer.tsx index 06070e3..9355b0a 100644 --- a/components/public-site-footer.tsx +++ b/components/public-site-footer.tsx @@ -1,6 +1,11 @@ import Link from 'next/link'; +import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_SIGN_IN_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing'; + const footerLinks = [ + { href: PUBLIC_CREATE_ACCOUNT_PATH, label: 'Create account' }, + { href: PUBLIC_START_FREE_PILOT_PATH, label: 'Start Free Pilot' }, + { href: PUBLIC_SIGN_IN_PATH, label: 'Sign in' }, { href: '/pricing', label: 'Pricing' }, { href: '/demo', label: 'Missed-Call Demo' }, { href: '/contact', label: 'Contact' }, diff --git a/components/public-site-nav.tsx b/components/public-site-nav.tsx index 2399c86..f82be4e 100644 --- a/components/public-site-nav.tsx +++ b/components/public-site-nav.tsx @@ -2,14 +2,15 @@ import Link from 'next/link'; import { Badge } from '@/components/ui/badge'; import { buttonVariants } from '@/components/ui/button'; +import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_SIGN_IN_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing'; const primaryLinks = [ + { href: '/demo', label: 'Demo' }, { href: '/pricing', label: 'Pricing' }, - { href: '/#how-it-works', label: 'How it works' }, - { href: '/#proof', label: 'Results' }, - { href: '/demo', label: 'See demo' }, { href: '/contact', label: 'Contact' }, { href: '/sms-consent', label: 'SMS Consent' }, + { href: '/privacy', label: 'Privacy' }, + { href: '/terms', label: 'Terms' }, ]; export function PublicSiteNav() { @@ -35,10 +36,13 @@ export function PublicSiteNav() {
- + Sign in - + + Create account + + Start Free Pilot
diff --git a/lib/log-sanitization.ts b/lib/log-sanitization.ts new file mode 100644 index 0000000..6d8c622 --- /dev/null +++ b/lib/log-sanitization.ts @@ -0,0 +1,73 @@ +import { maskPhoneForAudit } from '@/lib/phone'; + +const SECRET_KEY_PATTERN = /(token|secret|authorization|api[-_]?key|signature)/i; +const BODY_KEY_PATTERN = /(^body$|bodyPreview|messageBody|textBody)/i; +const PHONE_KEY_PATTERN = /(phone|from|to|destination)/i; +const SID_KEY_PATTERN = /sid/i; +const TWILIO_SID_PATTERN = /^[A-Z]{2}[0-9a-fA-F]{32}$/; + +function maskSid(value: string) { + if (value.length <= 8) return value; + return `${value.slice(0, 4)}...${value.slice(-4)}`; +} + +function redactEmbeddedSecrets(value: string) { + return value + .replace(/(Bearer\s+)[^\s]+/gi, '$1[redacted]') + .replace(/(Basic\s+)[A-Za-z0-9+/=]+/gi, '$1[redacted]') + .replace(/([?&](?:token|webhook_token|auth|signature|api_key)=)[^&]+/gi, '$1[redacted]'); +} + +function sanitizeString(value: string, key: string) { + const trimmed = value.trim(); + if (!trimmed) return trimmed; + + if (SECRET_KEY_PATTERN.test(key)) { + return '[redacted]'; + } + + if (BODY_KEY_PATTERN.test(key)) { + return `[redacted:${trimmed.length} chars]`; + } + + if (SID_KEY_PATTERN.test(key) && TWILIO_SID_PATTERN.test(trimmed)) { + return maskSid(trimmed); + } + + if (PHONE_KEY_PATTERN.test(key)) { + const maskedPhone = maskPhoneForAudit(trimmed); + if (maskedPhone) return maskedPhone; + } + + const redacted = redactEmbeddedSecrets(trimmed); + return redacted.length > 240 ? `${redacted.slice(0, 239)}…` : redacted; +} + +function sanitizeArray(values: unknown[], key: string, depth: number): unknown[] { + if (depth >= 3) return ['[truncated]']; + return values.slice(0, 12).map((value) => sanitizeLogValue(value, key, depth + 1)); +} + +function sanitizeObject(value: Record, depth: number) { + if (depth >= 3) return '[truncated]'; + + const output: Record = {}; + for (const [entryKey, entryValue] of Object.entries(value).slice(0, 24)) { + output[entryKey] = sanitizeLogValue(entryValue, entryKey, depth + 1); + } + return output; +} + +export function sanitizeLogValue(value: unknown, key = '', depth = 0): unknown { + if (value === null || value === undefined) return value; + if (typeof value === 'string') return sanitizeString(value, key); + if (typeof value === 'number' || typeof value === 'boolean') return value; + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) return sanitizeArray(value, key, depth); + if (typeof value === 'object') return sanitizeObject(value as Record, depth); + return String(value); +} + +export function sanitizeLogFields(fields: Record) { + return sanitizeObject(fields, 0) as Record; +} diff --git a/lib/observability.ts b/lib/observability.ts index e60b6f1..68d5d69 100644 --- a/lib/observability.ts +++ b/lib/observability.ts @@ -81,13 +81,14 @@ export function withCorrelationIdHeader(response: T, correla } export function reportApplicationError(input: ReportErrorInput) { + const metadata = sanitizeLogFields(input.metadata ?? {}); const errorMessage = input.error === undefined ? 'unknown_error' : toErrorMessage(input.error); const payload: ErrorReportPayload = { source: input.source, event: input.event, correlationId: input.correlationId, error: errorMessage, - metadata: input.metadata ?? {}, + metadata, timestamp: new Date().toISOString(), }; @@ -96,3 +97,4 @@ export function reportApplicationError(input: ReportErrorInput) { if (input.alert === false) return; void dispatchAlert(payload); } +import { sanitizeLogFields } from '@/lib/log-sanitization'; diff --git a/lib/public-auth-routing.ts b/lib/public-auth-routing.ts new file mode 100644 index 0000000..73e723d --- /dev/null +++ b/lib/public-auth-routing.ts @@ -0,0 +1,35 @@ +export const PUBLIC_CREATE_ACCOUNT_PATH = '/sign-up?intent=create-account'; +export const PUBLIC_START_FREE_PILOT_PATH = '/start-free-pilot'; +export const PUBLIC_SIGN_IN_PATH = '/sign-in'; +export const OWNER_DASHBOARD_PATH = '/app'; +export const OWNER_ONBOARDING_PATH = '/app/onboarding?source=public-sign-up'; +export const ADMIN_NEW_BUSINESS_PILOT_PATH = '/admin?intent=new-business-pilot'; + +type SignedInRoutingState = { + hasBusiness: boolean; + isAdmin: boolean; +}; + +type PublicPilotRoutingState = SignedInRoutingState & { + isAuthenticated: boolean; +}; + +export function resolveSignedInAppDestination(state: SignedInRoutingState) { + if (state.isAdmin) { + return ADMIN_NEW_BUSINESS_PILOT_PATH; + } + + if (state.hasBusiness) { + return OWNER_DASHBOARD_PATH; + } + + return OWNER_ONBOARDING_PATH; +} + +export function resolvePublicPilotDestination(state: PublicPilotRoutingState) { + if (!state.isAuthenticated) { + return '/sign-up?intent=pilot'; + } + + return resolveSignedInAppDestination(state); +} diff --git a/lib/security-headers.ts b/lib/security-headers.ts index 0f6d1cb..078026a 100644 --- a/lib/security-headers.ts +++ b/lib/security-headers.ts @@ -4,6 +4,25 @@ function isProductionEnv(env: EnvMap) { return env.NODE_ENV === 'production'; } +function buildContentSecurityPolicy() { + return [ + "default-src 'self'", + "base-uri 'self'", + "frame-ancestors 'none'", + "object-src 'none'", + "form-action 'self' https://*.clerk.com https://*.clerk.accounts.dev https://checkout.stripe.com https://billing.stripe.com", + "script-src 'self' 'unsafe-inline' https://js.stripe.com https://*.clerk.com https://*.clerk.accounts.dev", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "img-src 'self' data: blob: https:", + "font-src 'self' data: https://fonts.gstatic.com", + "connect-src 'self' https://api.stripe.com https://checkout.stripe.com https://billing.stripe.com https://*.clerk.com https://*.clerk.accounts.dev", + "frame-src 'self' https://js.stripe.com https://hooks.stripe.com https://*.clerk.com https://*.clerk.accounts.dev", + "media-src 'self' blob:", + "manifest-src 'self'", + 'upgrade-insecure-requests', + ].join('; '); +} + export function getSecurityHeaders(env: EnvMap = process.env): Record { const headers: Record = { 'X-Content-Type-Options': 'nosniff', @@ -14,6 +33,7 @@ export function getSecurityHeaders(env: EnvMap = process.env): Record { const env = { FOUNDER_CLERK_USER_ID: 'user_founder', @@ -12,3 +18,23 @@ test('isFounderUserId only authorizes the configured founder account', () => { assert.equal(isFounderUserId('user_customer', env), false); assert.equal(isFounderUserId(null, env), false); }); + +test('admin pages and customer-mode routes require real admin authorization', () => { + const adminPage = read('app/admin/page.tsx'); + const adminBusinessPage = read('app/admin/[businessId]/page.tsx'); + const adminActions = read('app/admin/actions.ts'); + const openCustomerRoute = read('app/admin/[businessId]/open-customer/route.ts'); + const exitCustomerModeRoute = read('app/admin/exit-customer-mode/route.ts'); + const appLayout = read('app/app/layout.tsx'); + const adminContext = read('lib/admin-customer-context.ts'); + + assert.match(adminPage, /const admin = await requireAdmin\(\)/); + assert.match(adminBusinessPage, /await requireAdmin\(\)/); + assert.match(adminActions, /const admin = await requireAdmin\(\)/); + assert.match(openCustomerRoute, /const adminSession = await getAdminSession\(\)/); + assert.match(openCustomerRoute, /if \(!adminSession\.isAdmin\)/); + assert.match(exitCustomerModeRoute, /const adminSession = await getAdminSession\(\)/); + assert.match(exitCustomerModeRoute, /if \(!adminSession\.isAdmin\)/); + assert.match(appLayout, /const adminCustomerContext = await getAdminCustomerActingContext\(\)/); + assert.match(adminContext, /if \(!adminSession\?\.isAdmin\)/); +}); diff --git a/tests/legal-pages.test.ts b/tests/legal-pages.test.ts index ecc49bd..fba441f 100644 --- a/tests/legal-pages.test.ts +++ b/tests/legal-pages.test.ts @@ -74,14 +74,25 @@ test('public-facing surfaces link to trust and contact routes', () => { assert.match(home, /href="\/pricing"/); assert.match(home, /href="\/sms-consent"/); + assert.match(home, /PUBLIC_CREATE_ACCOUNT_PATH/); + assert.match(home, /PUBLIC_START_FREE_PILOT_PATH/); + assert.match(nav, /href: '\/demo'/); assert.match(nav, /href: '\/pricing'/); assert.match(nav, /href: '\/contact'/); assert.match(nav, /href: '\/sms-consent'/); + assert.match(nav, /href: '\/privacy'/); + assert.match(nav, /href: '\/terms'/); + assert.match(nav, /PUBLIC_SIGN_IN_PATH/); + assert.match(nav, /PUBLIC_CREATE_ACCOUNT_PATH/); + assert.match(nav, /PUBLIC_START_FREE_PILOT_PATH/); assert.match(footer, /href: '\/privacy'/); assert.match(footer, /href: '\/terms'/); assert.match(footer, /href: '\/refund'/); assert.match(footer, /href: '\/contact'/); assert.match(footer, /href: '\/sms-consent'/); + assert.match(footer, /PUBLIC_CREATE_ACCOUNT_PATH/); + assert.match(footer, /PUBLIC_START_FREE_PILOT_PATH/); + assert.match(footer, /PUBLIC_SIGN_IN_PATH/); assert.match(billing, /href="\/pricing"/); assert.match(billing, /href="\/refund"/); diff --git a/tests/log-sanitization.test.ts b/tests/log-sanitization.test.ts new file mode 100644 index 0000000..42f72ca --- /dev/null +++ b/tests/log-sanitization.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { sanitizeLogFields, sanitizeLogValue } from '../lib/log-sanitization.ts'; + +test('sanitizeLogFields redacts secrets, bodies, phones, and SIDs', () => { + const sanitized = sanitizeLogFields({ + authToken: 'super-secret-token', + body: 'Customer said their AC is out and needs help now.', + fromPhone: '+15551234567', + messageSid: 'SM1234567890abcdef1234567890abcdef', + safeFlag: true, + }); + + assert.deepEqual(sanitized, { + authToken: '[redacted]', + body: '[redacted:49 chars]', + fromPhone: '+1***4567', + messageSid: 'SM12...cdef', + safeFlag: true, + }); +}); + +test('sanitizeLogValue redacts embedded credentials and truncates deep payloads safely', () => { + const sanitizedUrl = sanitizeLogValue( + 'Bearer supersecrettoken https://example.com/api/twilio/status?webhook_token=abc123', + 'details' + ); + const nested = sanitizeLogValue({ + level1: { + level2: { + level3: { + level4: 'too deep', + }, + }, + }, + }); + + assert.equal(sanitizedUrl, 'Bearer [redacted] https://example.com/api/twilio/status?webhook_token=[redacted]'); + assert.deepEqual(nested, { + level1: { + level2: { + level3: '[truncated]', + }, + }, + }); +}); diff --git a/tests/public-auth-routing.test.ts b/tests/public-auth-routing.test.ts index dd41b71..1f49926 100644 --- a/tests/public-auth-routing.test.ts +++ b/tests/public-auth-routing.test.ts @@ -3,6 +3,14 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import test from 'node:test'; +import { + ADMIN_NEW_BUSINESS_PILOT_PATH, + OWNER_DASHBOARD_PATH, + OWNER_ONBOARDING_PATH, + resolvePublicPilotDestination, + resolveSignedInAppDestination, +} from '../lib/public-auth-routing.ts'; + function read(relativePath: string) { return readFileSync(path.join(process.cwd(), relativePath), 'utf8'); } @@ -12,10 +20,13 @@ const invalidNestedButtonPattern = /]*>\s*