From 3b5b12e84fb5bb8037147c218c13245f5d695420 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Mon, 2 Mar 2026 00:02:25 -0500 Subject: [PATCH 1/2] hardening: g15 provider preflight workflow --- README.md | 2 + docs/EXTERNAL_SETUP_CHECKLIST.md | 1 + lib/provider-preflight.ts | 346 +++++++++++++++++++++++++++++++ package.json | 1 + scripts/preflight_checklist.md | 1 + scripts/provider_preflight.ts | 45 ++++ tests/provider-preflight.test.ts | 93 +++++++++ 7 files changed, 489 insertions(+) create mode 100644 lib/provider-preflight.ts create mode 100644 scripts/provider_preflight.ts create mode 100644 tests/provider-preflight.test.ts diff --git a/README.md b/README.md index 94fb231..1cc8e30 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Optional helper commands: ```bash npm run webhooks:print +npm run preflight:providers npm run db:smoke ``` @@ -292,6 +293,7 @@ Prisma models included: 2. Import project in Vercel. 3. Add all environment variables from `.env.local` (or from your secret manager). - Quick check: `npm run env:check` + - Provider parity check: `npm run preflight:providers` 4. Set `NEXT_PUBLIC_APP_URL` to your production origin, e.g. `https://app.example.com`. 5. Run Prisma migrations against your production database: - Either via CI/CD step: `npx prisma migrate deploy` diff --git a/docs/EXTERNAL_SETUP_CHECKLIST.md b/docs/EXTERNAL_SETUP_CHECKLIST.md index fd04745..4e88470 100644 --- a/docs/EXTERNAL_SETUP_CHECKLIST.md +++ b/docs/EXTERNAL_SETUP_CHECKLIST.md @@ -73,6 +73,7 @@ Optional quick checks: ```bash npm run env:check +npm run preflight:providers npm run typecheck ``` diff --git a/lib/provider-preflight.ts b/lib/provider-preflight.ts new file mode 100644 index 0000000..45fe8b5 --- /dev/null +++ b/lib/provider-preflight.ts @@ -0,0 +1,346 @@ +import { resolveConfiguredAppBaseUrl } from './app-url.ts'; + +export type ProviderPreflightStatus = 'PASS' | 'FAIL'; + +export type ProviderPreflightCheck = { + id: 'clerk' | 'stripe' | 'twilio' | 'database'; + title: string; + status: ProviderPreflightStatus; + details: string[]; + fixes: string[]; +}; + +type EnvMap = Readonly>; + +const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); + +function readEnv(env: EnvMap, key: string) { + return env[key]?.trim() ?? ''; +} + +function readBooleanEnv(env: EnvMap, key: string) { + const value = readEnv(env, key).toLowerCase(); + return value === '1' || value === 'true' || value === 'yes' || value === 'on'; +} + +function buildCheck( + id: ProviderPreflightCheck['id'], + title: string, + failures: string[], + details: string[], + fixes: string[] +): ProviderPreflightCheck { + if (failures.length > 0) { + return { + id, + title, + status: 'FAIL', + details: [...details, ...failures.map((failure) => `Failed: ${failure}`)], + fixes, + }; + } + + return { + id, + title, + status: 'PASS', + details, + fixes: [], + }; +} + +function getAppUrlContext(env: EnvMap) { + const resolution = resolveConfiguredAppBaseUrl(env); + if (!resolution.appUrlResolved) { + return { + baseUrl: null, + baseOrigin: null, + sourceUsed: resolution.sourceUsed, + resolution, + }; + } + + const parsed = new URL(resolution.appUrlResolved); + return { + baseUrl: resolution.appUrlResolved, + baseOrigin: parsed.origin, + sourceUsed: resolution.sourceUsed, + resolution, + }; +} + +function resolveRouteUrl(raw: string, fallbackPath: string, baseOrigin: string) { + const candidate = raw.trim() || fallbackPath; + if (candidate.startsWith('/')) { + return { ok: true, resolved: new URL(candidate, `${baseOrigin}/`).toString(), reason: null as string | null }; + } + + try { + const parsed = new URL(candidate); + if (parsed.origin !== baseOrigin) { + return { + ok: false, + resolved: null, + reason: `must match app origin (${baseOrigin}); got ${parsed.origin}`, + }; + } + return { ok: true, resolved: parsed.toString(), reason: null as string | null }; + } catch { + return { ok: false, resolved: null, reason: 'must be a relative path ("/sign-in") or absolute URL' }; + } +} + +function redactWebhookToken(url: string) { + const parsed = new URL(url); + if (parsed.searchParams.has('webhook_token')) { + parsed.searchParams.set('webhook_token', 'REDACTED'); + } + return parsed.toString(); +} + +export function runClerkPreflight(env: EnvMap = process.env): ProviderPreflightCheck { + const failures: string[] = []; + const details: string[] = []; + const fixes = [ + 'Set NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY in target env.', + 'Set NEXT_PUBLIC_CLERK_SIGN_IN_URL and NEXT_PUBLIC_CLERK_SIGN_UP_URL to relative app routes (/sign-in, /sign-up) or same-origin absolute URLs.', + 'In Clerk Dashboard, allow the app origin and redirect URLs derived from NEXT_PUBLIC_APP_URL.', + ]; + + const app = getAppUrlContext(env); + if (!app.baseUrl || !app.baseOrigin) { + failures.push('NEXT_PUBLIC_APP_URL (or Vercel fallback) is missing/invalid, so Clerk origin parity cannot be validated.'); + } else { + details.push(`App base URL: ${app.baseUrl} (${app.sourceUsed ?? 'unknown source'})`); + } + + const publishableKey = readEnv(env, 'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY'); + const secretKey = readEnv(env, 'CLERK_SECRET_KEY'); + + if (!publishableKey) { + failures.push('NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY is missing.'); + } else if (!publishableKey.startsWith('pk_')) { + failures.push('NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY must start with "pk_".'); + } else { + details.push('Publishable key format: looks valid (pk_*)'); + } + + if (!secretKey) { + failures.push('CLERK_SECRET_KEY is missing.'); + } else if (!secretKey.startsWith('sk_')) { + failures.push('CLERK_SECRET_KEY must start with "sk_".'); + } else { + details.push('Secret key format: looks valid (sk_*)'); + } + + if (app.baseOrigin) { + const signIn = resolveRouteUrl(readEnv(env, 'NEXT_PUBLIC_CLERK_SIGN_IN_URL'), '/sign-in', app.baseOrigin); + if (!signIn.ok) { + failures.push(`NEXT_PUBLIC_CLERK_SIGN_IN_URL ${signIn.reason}.`); + } else { + details.push(`Sign-in URL resolves to: ${signIn.resolved}`); + } + + const signUp = resolveRouteUrl(readEnv(env, 'NEXT_PUBLIC_CLERK_SIGN_UP_URL'), '/sign-up', app.baseOrigin); + if (!signUp.ok) { + failures.push(`NEXT_PUBLIC_CLERK_SIGN_UP_URL ${signUp.reason}.`); + } else { + details.push(`Sign-up URL resolves to: ${signUp.resolved}`); + } + } + + return buildCheck('clerk', 'Clerk URLs/origins consistency', failures, details, fixes); +} + +export function runStripePreflight(env: EnvMap = process.env): ProviderPreflightCheck { + const failures: string[] = []; + const details: string[] = []; + const fixes = [ + 'Set STRIPE_WEBHOOK_SECRET from Stripe Dashboard -> Developers -> Webhooks.', + 'Set NEXT_PUBLIC_APP_URL to the public HTTPS app origin and ensure /api/stripe/webhook is deployed.', + 'In Stripe Dashboard, point webhook endpoint to /api/stripe/webhook.', + ]; + + const app = getAppUrlContext(env); + if (!app.baseUrl) { + failures.push('NEXT_PUBLIC_APP_URL (or Vercel fallback) is missing/invalid.'); + } + + const stripeWebhookSecret = readEnv(env, 'STRIPE_WEBHOOK_SECRET'); + if (!stripeWebhookSecret) { + failures.push('STRIPE_WEBHOOK_SECRET is missing.'); + } else if (!stripeWebhookSecret.startsWith('whsec_')) { + failures.push('STRIPE_WEBHOOK_SECRET must start with "whsec_".'); + } else { + details.push('Webhook secret format: looks valid (whsec_*)'); + } + + if (app.baseUrl) { + const endpoint = new URL('/api/stripe/webhook', `${app.baseUrl}/`); + details.push(`Expected Stripe webhook endpoint: ${endpoint.toString()}`); + if (LOCAL_HOSTS.has(endpoint.hostname)) { + failures.push('Stripe webhook endpoint hostname is local-only and not reachable from Stripe.'); + } else if (endpoint.protocol !== 'https:') { + failures.push('Stripe webhook endpoint must use HTTPS.'); + } else { + details.push('Reachability assumption: endpoint uses public HTTPS origin.'); + } + } + + return buildCheck('stripe', 'Stripe webhook secret + endpoint assumptions', failures, details, fixes); +} + +function normalizeUrlForCompare(rawUrl: string) { + const parsed = new URL(rawUrl); + parsed.hash = ''; + if (!parsed.searchParams.has('webhook_token')) { + parsed.searchParams.sort(); + return parsed.toString().replace(/\/$/, ''); + } + parsed.searchParams.sort(); + return parsed.toString().replace(/\/$/, ''); +} + +export function runTwilioPreflight(env: EnvMap = process.env): ProviderPreflightCheck { + const failures: string[] = []; + const details: string[] = []; + const fixes = [ + 'Set NEXT_PUBLIC_APP_URL to the exact public app origin used for Twilio webhooks.', + 'Set TWILIO_WEBHOOK_AUTH_TOKEN (used for tooling/local token-mode checks).', + 'Run npm run webhooks:print and compare each URL with Twilio Console number configuration.', + ]; + + const app = getAppUrlContext(env); + const webhookToken = readEnv(env, 'TWILIO_WEBHOOK_AUTH_TOKEN'); + const signatureValidationEnabled = readBooleanEnv(env, 'TWILIO_VALIDATE_SIGNATURE'); + const productionLike = readEnv(env, 'NODE_ENV') === 'production' || readEnv(env, 'VERCEL_ENV') === 'production'; + + if (!app.baseUrl) { + failures.push('NEXT_PUBLIC_APP_URL (or Vercel fallback) is missing/invalid.'); + return buildCheck('twilio', 'Twilio webhook target parity', failures, details, fixes); + } + + const voiceUrl = new URL('/api/twilio/voice', `${app.baseUrl}/`); + const smsUrl = new URL('/api/twilio/sms', `${app.baseUrl}/`); + const statusUrl = new URL('/api/twilio/status', `${app.baseUrl}/`); + + if (webhookToken) { + voiceUrl.searchParams.set('webhook_token', webhookToken); + smsUrl.searchParams.set('webhook_token', webhookToken); + statusUrl.searchParams.set('webhook_token', webhookToken); + details.push(`Expected voice webhook URL: ${redactWebhookToken(voiceUrl.toString())}`); + details.push(`Expected SMS webhook URL: ${redactWebhookToken(smsUrl.toString())}`); + details.push(`Expected status webhook URL: ${redactWebhookToken(statusUrl.toString())}`); + } else { + failures.push('TWILIO_WEBHOOK_AUTH_TOKEN is missing.'); + } + + const hostname = new URL(app.baseUrl).hostname; + if (productionLike && LOCAL_HOSTS.has(hostname)) { + failures.push('Production-like environment cannot use local-only app URLs for Twilio webhooks.'); + } + + if (signatureValidationEnabled && app.sourceUsed !== 'NEXT_PUBLIC_APP_URL') { + failures.push('TWILIO_VALIDATE_SIGNATURE=true requires explicit NEXT_PUBLIC_APP_URL to avoid signature URL drift.'); + } + + const voiceConfigured = readEnv(env, 'TWILIO_WEBHOOK_VOICE_URL'); + const smsConfigured = readEnv(env, 'TWILIO_WEBHOOK_SMS_URL'); + const statusConfigured = readEnv(env, 'TWILIO_WEBHOOK_STATUS_URL'); + + if (voiceConfigured) { + try { + const expected = normalizeUrlForCompare(voiceUrl.toString()); + const configured = normalizeUrlForCompare(voiceConfigured); + if (expected !== configured) { + failures.push('TWILIO_WEBHOOK_VOICE_URL does not match expected value derived from NEXT_PUBLIC_APP_URL.'); + } else { + details.push('TWILIO_WEBHOOK_VOICE_URL parity: matched'); + } + } catch { + failures.push('TWILIO_WEBHOOK_VOICE_URL is not a valid URL.'); + } + } + + if (smsConfigured) { + try { + const expected = normalizeUrlForCompare(smsUrl.toString()); + const configured = normalizeUrlForCompare(smsConfigured); + if (expected !== configured) { + failures.push('TWILIO_WEBHOOK_SMS_URL does not match expected value derived from NEXT_PUBLIC_APP_URL.'); + } else { + details.push('TWILIO_WEBHOOK_SMS_URL parity: matched'); + } + } catch { + failures.push('TWILIO_WEBHOOK_SMS_URL is not a valid URL.'); + } + } + + if (statusConfigured) { + try { + const expected = normalizeUrlForCompare(statusUrl.toString()); + const configured = normalizeUrlForCompare(statusConfigured); + if (expected !== configured) { + failures.push('TWILIO_WEBHOOK_STATUS_URL does not match expected value derived from NEXT_PUBLIC_APP_URL.'); + } else { + details.push('TWILIO_WEBHOOK_STATUS_URL parity: matched'); + } + } catch { + failures.push('TWILIO_WEBHOOK_STATUS_URL is not a valid URL.'); + } + } + + details.push('Parity assumption: Twilio Console webhooks should exactly match the expected URLs above.'); + + return buildCheck('twilio', 'Twilio webhook target parity', failures, details, fixes); +} + +export async function runDatabasePreflight( + dbHealthCheck: () => Promise +): Promise { + const details: string[] = []; + const fixes = [ + 'Verify DATABASE_URL and DIRECT_DATABASE_URL credentials/host/SSL settings.', + 'Ensure target Postgres is reachable from this runtime and run npm run db:smoke for deeper checks.', + ]; + + try { + await dbHealthCheck(); + details.push('Database health query succeeded (SELECT 1).'); + return { + id: 'database', + title: 'Database connection health', + status: 'PASS', + details, + fixes: [], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + id: 'database', + title: 'Database connection health', + status: 'FAIL', + details: [`Failed: ${message}`], + fixes, + }; + } +} + +export async function runProviderPreflight( + dbHealthCheck: () => Promise, + env: EnvMap = process.env +) { + const checks: ProviderPreflightCheck[] = [ + runClerkPreflight(env), + runStripePreflight(env), + runTwilioPreflight(env), + await runDatabasePreflight(dbHealthCheck), + ]; + + const failed = checks.filter((check) => check.status === 'FAIL'); + return { + checks, + passed: failed.length === 0, + failedCount: failed.length, + }; +} diff --git a/package.json b/package.json index cbf98b8..9410096 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "db:studio": "prisma studio", "env:check": "node --experimental-strip-types --experimental-specifier-resolution=node scripts/check_env.ts", "webhooks:print": "node --experimental-strip-types --experimental-specifier-resolution=node scripts/print_webhook_urls.ts", + "preflight:providers": "node --experimental-strip-types --experimental-specifier-resolution=node scripts/provider_preflight.ts", "preflight": "npm run env:check && npm run db:validate && npm test && npm run lint && npm run typecheck && npm run build", "postinstall": "prisma generate" }, diff --git a/scripts/preflight_checklist.md b/scripts/preflight_checklist.md index 8e87e70..1cf432e 100644 --- a/scripts/preflight_checklist.md +++ b/scripts/preflight_checklist.md @@ -23,6 +23,7 @@ Note: this file does not add or require new scripts. ## 3) Twilio / Stripe Preflight (Using Existing Commands + Provider Consoles) - [ ] Print expected Twilio webhook URLs: `npm run webhooks:print` +- [ ] Run consolidated provider preflight report: `npm run preflight:providers` - [ ] Verify Twilio Console webhook targets match printed URLs. - [ ] Verify Stripe webhook endpoint points to `/api/stripe/webhook` on the target environment. - [ ] Replay or trigger Stripe test events (`checkout.session.completed`, `customer.subscription.*`, `invoice.payment_*`) in Stripe test mode. diff --git a/scripts/provider_preflight.ts b/scripts/provider_preflight.ts new file mode 100644 index 0000000..74d392a --- /dev/null +++ b/scripts/provider_preflight.ts @@ -0,0 +1,45 @@ +import process from 'node:process'; + +import { PrismaClient } from '@prisma/client'; + +import { runProviderPreflight } from '../lib/provider-preflight.ts'; +import { loadLocalEnvFiles } from './load-env.ts'; + +async function main() { + const loadedFiles = loadLocalEnvFiles(); + const prisma = new PrismaClient(); + + try { + const report = await runProviderPreflight(async () => { + await prisma.$queryRaw`SELECT 1`; + }); + + console.log('CallbackCloser provider preflight'); + console.log(`- Loaded env files: ${loadedFiles.join(', ') || '(none)'}`); + console.log(''); + + for (const check of report.checks) { + console.log(`[${check.status}] ${check.title}`); + for (const detail of check.details) { + console.log(` - ${detail}`); + } + if (check.status === 'FAIL') { + for (const fix of check.fixes) { + console.log(` - Fix: ${fix}`); + } + } + console.log(''); + } + + const passedCount = report.checks.length - report.failedCount; + console.log(`Overall: ${report.passed ? 'PASS' : 'FAIL'} (${passedCount}/${report.checks.length} checks passed)`); + + if (!report.passed) { + process.exit(1); + } + } finally { + await prisma.$disconnect().catch(() => undefined); + } +} + +await main(); diff --git a/tests/provider-preflight.test.ts b/tests/provider-preflight.test.ts new file mode 100644 index 0000000..120f146 --- /dev/null +++ b/tests/provider-preflight.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + runClerkPreflight, + runDatabasePreflight, + runProviderPreflight, + runStripePreflight, + runTwilioPreflight, +} from '../lib/provider-preflight.ts'; + +function baseEnv(overrides: Record = {}) { + return { + NEXT_PUBLIC_APP_URL: 'https://callbackcloser.example.com', + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: 'pk_test_abc123', + CLERK_SECRET_KEY: 'sk_test_abc123', + NEXT_PUBLIC_CLERK_SIGN_IN_URL: '/sign-in', + NEXT_PUBLIC_CLERK_SIGN_UP_URL: '/sign-up', + STRIPE_WEBHOOK_SECRET: 'whsec_abc123', + TWILIO_WEBHOOK_AUTH_TOKEN: 'token_abc123', + NODE_ENV: 'development', + ...overrides, + }; +} + +test('runClerkPreflight passes for same-origin auth URLs', () => { + const check = runClerkPreflight(baseEnv()); + assert.equal(check.status, 'PASS'); +}); + +test('runClerkPreflight fails when sign-in URL origin mismatches app URL', () => { + const check = runClerkPreflight( + baseEnv({ + NEXT_PUBLIC_CLERK_SIGN_IN_URL: 'https://other.example.com/sign-in', + }) + ); + + assert.equal(check.status, 'FAIL'); + assert.ok(check.details.some((detail) => detail.includes('NEXT_PUBLIC_CLERK_SIGN_IN_URL'))); +}); + +test('runStripePreflight fails when webhook secret is missing', () => { + const check = runStripePreflight( + baseEnv({ + STRIPE_WEBHOOK_SECRET: '', + }) + ); + + assert.equal(check.status, 'FAIL'); + assert.ok(check.details.some((detail) => detail.includes('STRIPE_WEBHOOK_SECRET is missing'))); +}); + +test('runTwilioPreflight fails when explicit configured URLs drift from NEXT_PUBLIC_APP_URL', () => { + const check = runTwilioPreflight( + baseEnv({ + TWILIO_WEBHOOK_VOICE_URL: 'https://mismatch.example.com/api/twilio/voice?webhook_token=token_abc123', + TWILIO_WEBHOOK_SMS_URL: 'https://mismatch.example.com/api/twilio/sms?webhook_token=token_abc123', + TWILIO_WEBHOOK_STATUS_URL: 'https://mismatch.example.com/api/twilio/status?webhook_token=token_abc123', + }) + ); + + assert.equal(check.status, 'FAIL'); + assert.ok(check.details.some((detail) => detail.includes('TWILIO_WEBHOOK_VOICE_URL does not match'))); +}); + +test('runDatabasePreflight reports pass and fail outcomes', async () => { + const passCheck = await runDatabasePreflight(async () => undefined); + assert.equal(passCheck.status, 'PASS'); + + const failCheck = await runDatabasePreflight(async () => { + throw new Error('db down'); + }); + assert.equal(failCheck.status, 'FAIL'); + assert.ok(failCheck.details.some((detail) => detail.includes('db down'))); +}); + +test('runProviderPreflight aggregates provider checks and exposes fail count', async () => { + const report = await runProviderPreflight(async () => undefined, baseEnv()); + assert.equal(report.passed, true); + assert.equal(report.failedCount, 0); + + const failedReport = await runProviderPreflight( + async () => { + throw new Error('db down'); + }, + baseEnv({ + STRIPE_WEBHOOK_SECRET: '', + }) + ); + + assert.equal(failedReport.passed, false); + assert.equal(failedReport.failedCount, 2); +}); From 2830526061655f330f1528a66719f825e05ba78c Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Mon, 2 Mar 2026 00:02:59 -0500 Subject: [PATCH 2/2] docs: mark g15 done in production readiness log --- docs/PRODUCTION_READINESS_GAPS.md | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/PRODUCTION_READINESS_GAPS.md b/docs/PRODUCTION_READINESS_GAPS.md index 9989ecd..31fcfce 100644 --- a/docs/PRODUCTION_READINESS_GAPS.md +++ b/docs/PRODUCTION_READINESS_GAPS.md @@ -483,3 +483,39 @@ Dependencies: G4 (recommended) - `npm run env:check` -> PASS - Notes: - No functional regressions observed in local validation gates. + +- 2026-03-02 - G15 (DONE) + - Branch: `hardening/g15-provider-preflight` + - What changed: + - Added provider preflight core checks in `lib/provider-preflight.ts`: + - Clerk URL/origin consistency validation + - Stripe webhook secret + endpoint reachability assumptions + - Twilio webhook target parity derivation from app URL (with optional explicit URL parity env checks) + - DB connectivity health check contract + - Added executable provider preflight command: + - `scripts/provider_preflight.ts` + - npm script: `npm run preflight:providers` + - Added focused tests with mocked DB behavior and env-driven parity cases: + - `tests/provider-preflight.test.ts` + - Updated operator docs/checklists to include provider preflight: + - `README.md` + - `docs/EXTERNAL_SETUP_CHECKLIST.md` + - `scripts/preflight_checklist.md` + - Commands run + results: + - `npm test` -> PASS (40/40) + - `npm run lint` -> PASS + - `npm run build` -> PASS + - `npm run typecheck` -> PASS + - `npm run env:check` -> PASS + - `npm run preflight:providers` -> FAIL (database unreachable from current runtime; script produced actionable fix output and correctly marked FAIL) + - Files touched: + - `lib/provider-preflight.ts` + - `scripts/provider_preflight.ts` + - `tests/provider-preflight.test.ts` + - `package.json` + - `README.md` + - `docs/EXTERNAL_SETUP_CHECKLIST.md` + - `scripts/preflight_checklist.md` + - `docs/PRODUCTION_READINESS_GAPS.md` + - Commit SHA: + - `2ced0fc`