diff --git a/app/api/admin/diagnostics/route.ts b/app/api/admin/diagnostics/route.ts new file mode 100644 index 0000000..759ffc9 --- /dev/null +++ b/app/api/admin/diagnostics/route.ts @@ -0,0 +1,101 @@ +import type { NextRequest } from "next/server"; +import packageJson from "@/package.json"; +import { + adminJsonResponse, + DEFAULT_ADMIN_EXPORT_TOKEN, + validateAdminRequest, +} from "@/lib/adminAuth.server"; +import { getPrismaClient } from "@/lib/prisma"; +import { + getMaxSubmissionBodyBytes, + getSubmissionRateLimitMax, + getSubmissionRateLimitWindowMs, + isDatabaseConfigured, + isServerSubmissionEnabled, +} from "@/lib/serverConfig"; + +export const runtime = "nodejs"; + +type DiagnosticsPayload = { + ok: true; + nodeEnv: string; + appVersion: string; + serverTime: string; + databaseConfigured: boolean; + databaseReachable: boolean; + serverSubmissionEnabled: boolean; + googleSheetsWebhookConfigured: boolean; + googleSheetsSecretConfigured: boolean; + adminTokenConfigured: boolean; + adminTokenUsesDefaultValue: boolean; + maxSubmissionBodyBytes: number; + submissionRateLimitWindowMs: number; + submissionRateLimitMax: number; + latestSubmissionAt: string | null; + totalSubmissions: number; +}; + +export async function GET(request: NextRequest) { + const auth = validateAdminRequest(request); + if (!auth.ok) { + return auth.response; + } + + const databaseConfigured = isDatabaseConfigured(); + const diagnostics: DiagnosticsPayload = { + ok: true, + nodeEnv: process.env.NODE_ENV || "unknown", + appVersion: process.env.npm_package_version || packageJson.version, + serverTime: new Date().toISOString(), + databaseConfigured, + databaseReachable: false, + serverSubmissionEnabled: isServerSubmissionEnabled(), + googleSheetsWebhookConfigured: Boolean( + process.env.GOOGLE_SHEETS_WEBHOOK_URL?.trim(), + ), + googleSheetsSecretConfigured: Boolean( + process.env.GOOGLE_SHEETS_WEBHOOK_SECRET?.trim(), + ), + adminTokenConfigured: Boolean(process.env.ADMIN_EXPORT_TOKEN?.trim()), + adminTokenUsesDefaultValue: + process.env.ADMIN_EXPORT_TOKEN?.trim() === DEFAULT_ADMIN_EXPORT_TOKEN, + maxSubmissionBodyBytes: getMaxSubmissionBodyBytes(), + submissionRateLimitWindowMs: getSubmissionRateLimitWindowMs(), + submissionRateLimitMax: getSubmissionRateLimitMax(), + latestSubmissionAt: null, + totalSubmissions: 0, + }; + + if (!databaseConfigured) { + return adminJsonResponse(diagnostics); + } + + try { + const prisma = getPrismaClient(); + await prisma.$queryRaw`SELECT 1`; + + const [totalSubmissions, latestSubmission] = await Promise.all([ + prisma.researchSubmission.count(), + prisma.researchSubmission.findFirst({ + orderBy: { submittedAt: "desc" }, + select: { submittedAt: true }, + }), + ]); + + diagnostics.databaseReachable = true; + diagnostics.totalSubmissions = totalSubmissions; + diagnostics.latestSubmissionAt = + latestSubmission?.submittedAt.toISOString() ?? null; + + return adminJsonResponse(diagnostics); + } catch { + return adminJsonResponse( + { + ...diagnostics, + ok: false, + error: "Database is configured but unreachable.", + }, + { status: 503 }, + ); + } +} diff --git a/components/admin/AdminDashboard.tsx b/components/admin/AdminDashboard.tsx index 3dcb969..0e68442 100644 --- a/components/admin/AdminDashboard.tsx +++ b/components/admin/AdminDashboard.tsx @@ -56,6 +56,26 @@ type AdminSubmissionPageResponse = { nextCursor?: string; }; +type AdminDiagnosticsResponse = { + ok: boolean; + error?: string; + nodeEnv: string; + appVersion: string; + serverTime: string; + databaseConfigured: boolean; + databaseReachable: boolean; + serverSubmissionEnabled: boolean; + googleSheetsWebhookConfigured: boolean; + googleSheetsSecretConfigured: boolean; + adminTokenConfigured: boolean; + adminTokenUsesDefaultValue: boolean; + maxSubmissionBodyBytes: number; + submissionRateLimitWindowMs: number; + submissionRateLimitMax: number; + latestSubmissionAt: string | null; + totalSubmissions: number; +}; + type DownloadKind = "json" | "csv"; const SESSION_TOKEN_KEY = "hidden-cost-game-admin-export-token"; @@ -164,6 +184,8 @@ export function AdminDashboard() { const [token, setToken] = useState(""); const [rememberToken, setRememberToken] = useState(false); const [stats, setStats] = useState(null); + const [diagnostics, setDiagnostics] = + useState(null); const [submissions, setSubmissions] = useState([]); const [error, setError] = useState(""); const [status, setStatus] = useState(""); @@ -177,6 +199,7 @@ export function AdminDashboard() { ); const jsonCurl = `curl -H "Authorization: Bearer $ADMIN_EXPORT_TOKEN" \\\n "${curlBaseUrl}/api/admin/submissions?limit=500" \\\n -o submissions.json`; const csvCurl = `curl -H "Authorization: Bearer $ADMIN_EXPORT_TOKEN" \\\n "${curlBaseUrl}/api/admin/submissions.csv" \\\n -o submissions.csv`; + const diagnosticsCurl = `curl -H "Authorization: Bearer TOKEN" "${curlBaseUrl}/api/admin/diagnostics"`; useEffect(() => { const sessionToken = window.sessionStorage.getItem(SESSION_TOKEN_KEY); @@ -199,10 +222,15 @@ export function AdminDashboard() { setStatus("Loading dashboard…"); setError(""); setStats(null); + setDiagnostics(null); setSubmissions([]); try { - const [nextStats, nextSubmissions] = await Promise.all([ + const [nextDiagnostics, nextStats, nextSubmissions] = await Promise.all([ + fetchAdminJson( + "/api/admin/diagnostics", + trimmedToken, + ), fetchAdminJson("/api/admin/stats", trimmedToken), fetchAdminJson( "/api/admin/submissions?limit=20", @@ -217,6 +245,7 @@ export function AdminDashboard() { window.localStorage.removeItem(REMEMBERED_TOKEN_KEY); } + setDiagnostics(nextDiagnostics); setStats(nextStats); setSubmissions(nextSubmissions.items ?? []); setStatus("Dashboard loaded."); @@ -287,6 +316,7 @@ export function AdminDashboard() { setToken(""); setRememberToken(false); setStats(null); + setDiagnostics(null); setSubmissions([]); setStatus("Token removed from this browser."); setError(""); @@ -354,6 +384,10 @@ export function AdminDashboard() { ) : null} + {diagnostics ? ( + + ) : null} + {stats ? ( <>
@@ -536,6 +570,12 @@ export function AdminDashboard() { copied={copyStatus === "csv"} onCopy={() => handleCopy("csv", csvCurl)} /> + handleCopy("diagnostics", diagnosticsCurl)} + />
) : null} @@ -543,6 +583,97 @@ export function AdminDashboard() { ); } + +function DiagnosticsPanel({ + diagnostics, +}: { + diagnostics: AdminDiagnosticsResponse; +}) { + const badgeItems = [ + ["Database configured", diagnostics.databaseConfigured], + ["Database reachable", diagnostics.databaseReachable], + ["Server submission", diagnostics.serverSubmissionEnabled], + ["Sheets webhook", diagnostics.googleSheetsWebhookConfigured], + ["Sheets secret", diagnostics.googleSheetsSecretConfigured], + ["Admin token", diagnostics.adminTokenConfigured], + ] as const; + + return ( +
+
+

Production diagnostics

+

+ Safe environment and connectivity checks for VPS debugging. Secrets + and URLs are never shown. +

+
+
+ {badgeItems.map(([label, enabled]) => ( + + ))} + {diagnostics.adminTokenUsesDefaultValue ? ( + + Default admin token + + ) : null} +
+
+ + + + + + + + +
+
+ ); +} + +function StatusBadge({ label, enabled }: { label: string; enabled: boolean }) { + return ( + + {label}: {enabled ? "yes" : "no"} + + ); +} + +function DiagnosticDetail({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} + function MetricSection({ title, cards, diff --git a/lib/adminAuth.server.ts b/lib/adminAuth.server.ts index 4090d92..9acc20a 100644 --- a/lib/adminAuth.server.ts +++ b/lib/adminAuth.server.ts @@ -10,7 +10,7 @@ type AdminAuthResult = }; const BEARER_PREFIX = "Bearer "; -const PLACEHOLDER_ADMIN_TOKEN = "change-me-before-production"; +export const DEFAULT_ADMIN_EXPORT_TOKEN = "change-me-before-production"; export function validateAdminRequest(request: NextRequest): AdminAuthResult { const configuredToken = process.env.ADMIN_EXPORT_TOKEN?.trim(); @@ -63,7 +63,7 @@ export function withAdminNoStore(init: ResponseInit = {}): ResponseInit { } function isUnsafeProductionAdminToken(token: string): boolean { - return process.env.NODE_ENV === "production" && token === PLACEHOLDER_ADMIN_TOKEN; + return process.env.NODE_ENV === "production" && token === DEFAULT_ADMIN_EXPORT_TOKEN; } function constantTimeTokenEquals(a: string, b: string): boolean {