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
73 changes: 73 additions & 0 deletions app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { NextResponse } from 'next/server';

import { db } from '@/lib/db';
import { getConfiguredAppBaseUrl } from '@/lib/env.server';
import { getCorrelationIdFromRequest, withCorrelationIdHeader } from '@/lib/observability';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

const DB_PROBE_TIMEOUT_MS = 2000;

function withTimeout<T>(promise: Promise<T>, timeoutMs: number) {
return Promise.race<T>([
promise,
new Promise<T>((_, reject) => {
const timer = setTimeout(() => {
reject(new Error(`timeout_after_${timeoutMs}ms`));
}, timeoutMs);
timer.unref?.();
}),
]);
}

function hasValue(value: string | undefined) {
return Boolean(value?.trim());
}

function getEnvChecks() {
return {
appUrl: Boolean(getConfiguredAppBaseUrl()),
databaseUrl: hasValue(process.env.DATABASE_URL),
directDatabaseUrl: hasValue(process.env.DIRECT_DATABASE_URL),
clerk: hasValue(process.env.CLERK_SECRET_KEY) && hasValue(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY),
stripe: hasValue(process.env.STRIPE_SECRET_KEY) && hasValue(process.env.STRIPE_WEBHOOK_SECRET),
twilio: hasValue(process.env.TWILIO_ACCOUNT_SID) && hasValue(process.env.TWILIO_AUTH_TOKEN),
};
}

async function getDatabaseCheck() {
try {
await withTimeout(db.$queryRaw`SELECT 1`, DB_PROBE_TIMEOUT_MS);
return { ok: true as const, detail: 'ok' };
} catch (error) {
const detail = error instanceof Error ? error.message : 'db_probe_failed';
return { ok: false as const, detail };
Comment on lines +44 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid exposing DB error details in health response

The new unauthenticated /api/health endpoint stores error.message from the DB probe and returns it to clients via checks.database.detail. Connection/library error strings commonly include internal diagnostics (hostnames, driver details, schema hints), so this leaks operational information to any caller. For a public health check, return a generic failure detail and keep the raw error only in server logs.

Useful? React with 👍 / 👎.

}
}

export async function GET(request: Request) {
const correlationId = getCorrelationIdFromRequest(request);
const withCorrelation = (response: NextResponse) => withCorrelationIdHeader(response, correlationId);
const envChecks = getEnvChecks();
const dbCheck = await getDatabaseCheck();
const envReady = Object.values(envChecks).every(Boolean);
const ready = envReady && dbCheck.ok;

return withCorrelation(
NextResponse.json(
{
status: ready ? 'ok' : 'degraded',
timestamp: new Date().toISOString(),
checks: {
env: {
ready: envReady,
...envChecks,
},
database: dbCheck,
},
},
{ status: ready ? 200 : 503 }
)
);
}
50 changes: 43 additions & 7 deletions app/api/stripe/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';

import { logAuditEvent } from '@/lib/audit-log';
import { db } from '@/lib/db';
import { getConfiguredAppBaseUrl } from '@/lib/env.server';
import { getCorrelationIdFromRequest, reportApplicationError, withCorrelationIdHeader } from '@/lib/observability';
import { isAllowedRequestOrigin } from '@/lib/request-origin';
import { getStripe } from '@/lib/stripe';
import { absoluteUrl } from '@/lib/url';
import { checkoutSchema } from '@/lib/validators';
Expand All @@ -14,25 +18,32 @@ function errorRedirect(message: string) {
}

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 NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 });
return withCorrelation(NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 }));
}

const business = await db.business.findUnique({ where: { ownerClerkId: userId } });
if (!business) {
return NextResponse.redirect(absoluteUrl('/app/onboarding'), { status: 303 });
return withCorrelation(NextResponse.redirect(absoluteUrl('/app/onboarding'), { status: 303 }));
}

const formData = await request.formData();
const parsed = checkoutSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return errorRedirect('Invalid Stripe price selection');
return withCorrelation(errorRedirect('Invalid Stripe price selection'));
}

const allowedPrices = [process.env.STRIPE_PRICE_STARTER, process.env.STRIPE_PRICE_PRO].filter(Boolean);
if (!allowedPrices.includes(parsed.data.priceId)) {
return errorRedirect('Price ID is not allowed');
return withCorrelation(errorRedirect('Price ID is not allowed'));
}

try {
Expand Down Expand Up @@ -65,12 +76,37 @@ export async function POST(request: Request) {
});

if (!session.url) {
return errorRedirect('Stripe did not return a checkout URL');
return withCorrelation(errorRedirect('Stripe did not return a checkout URL'));
}

return NextResponse.redirect(session.url, { status: 303 });
logAuditEvent({
event: 'billing.checkout_session_created',
actorType: 'user',
actorId: userId,
businessId: business.id,
targetType: 'stripe_checkout_session',
targetId: session.id,
correlationId,
metadata: {
priceId: parsed.data.priceId,
hasExistingStripeCustomer: Boolean(business.stripeCustomerId),
},
});

return withCorrelation(NextResponse.redirect(session.url, { status: 303 }));
} catch (error) {
reportApplicationError({
source: 'stripe.checkout',
event: 'route_error',
correlationId,
error,
metadata: {
userId,
businessId: business.id,
},
alert: false,
});
const message = error instanceof Error ? error.message : 'Failed to create Stripe checkout session';
return errorRedirect(message);
return withCorrelation(errorRedirect(message));
}
}
47 changes: 42 additions & 5 deletions app/api/stripe/portal/route.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';

import { logAuditEvent } from '@/lib/audit-log';
import { db } from '@/lib/db';
import { getConfiguredAppBaseUrl } from '@/lib/env.server';
import { getCorrelationIdFromRequest, reportApplicationError, withCorrelationIdHeader } from '@/lib/observability';
import { isAllowedRequestOrigin } from '@/lib/request-origin';
import { getStripe } from '@/lib/stripe';
import { absoluteUrl } from '@/lib/url';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export async function POST() {
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 NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 });
return withCorrelation(NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 }));
}

const business = await db.business.findUnique({ where: { ownerClerkId: userId } });
if (!business?.stripeCustomerId) {
return NextResponse.redirect(absoluteUrl('/app/billing?error=No%20Stripe%20customer%20for%20this%20business'), { status: 303 });
return withCorrelation(
NextResponse.redirect(absoluteUrl('/app/billing?error=No%20Stripe%20customer%20for%20this%20business'), { status: 303 })
);
}

try {
Expand All @@ -26,9 +39,33 @@ export async function POST() {
return_url: absoluteUrl('/app/billing'),
});

return NextResponse.redirect(session.url, { status: 303 });
logAuditEvent({
event: 'billing.portal_session_created',
actorType: 'user',
actorId: userId,
businessId: business.id,
targetType: 'stripe_portal_session',
targetId: business.stripeCustomerId,
correlationId,
metadata: {
returnUrl: absoluteUrl('/app/billing'),
},
});

return withCorrelation(NextResponse.redirect(session.url, { status: 303 }));
} catch (error) {
reportApplicationError({
source: 'stripe.portal',
event: 'route_error',
correlationId,
error,
metadata: {
userId,
businessId: business.id,
},
alert: false,
});
const message = error instanceof Error ? error.message : 'Failed to open billing portal';
return NextResponse.redirect(absoluteUrl(`/app/billing?error=${encodeURIComponent(message)}`), { status: 303 });
return withCorrelation(NextResponse.redirect(absoluteUrl(`/app/billing?error=${encodeURIComponent(message)}`), { status: 303 }));
}
}
Loading