* feat(api): implement caching and logging in health check endpoint - Added in-memory caching for health check responses to reduce load on Postgres. - Introduced logging for error handling in health check. - Updated response structure to exclude error details from public responses. feat(api): enhance OAuth consent UI and scope handling - Improved consent UI to reflect exact requested scopes and added better user guidance. - Updated scope handling logic to ensure least-privilege access. - Enhanced styling for better user experience and accessibility. chore(docker): improve security and resource management in Docker setup - Updated Docker Compose configuration to enforce read-only file systems and resource limits. - Added health checks and logging options for better observability. - Introduced optional Caddy reverse proxy for TLS termination. fix(migrations): resolve ambiguity in create_company_with_owner function - Dropped orphaned 3-arg overload of create_company_with_owner function. - Recreated canonical 4-arg version with cash account seeding logic. - Ensured proper permissions for function execution in Postgres. * feat: enhance security checks for team membership in company creation
122 lines
4.1 KiB
TypeScript
122 lines
4.1 KiB
TypeScript
import { createClient } from '@supabase/supabase-js'
|
|
import { NextResponse } from 'next/server'
|
|
import { createLogger } from '@/lib/logger'
|
|
|
|
const log = createLogger('health')
|
|
|
|
const CACHE_TTL_MS = 5_000
|
|
|
|
type HealthBody = {
|
|
status: 'healthy' | 'unhealthy'
|
|
timestamp: string
|
|
version: string
|
|
}
|
|
|
|
type CheckResult = {
|
|
body: HealthBody
|
|
status: number
|
|
}
|
|
|
|
type CachedResult = CheckResult & { expires: number }
|
|
|
|
// In-memory cache shared across requests in the same process. Docker's
|
|
// healthcheck polls every 30 s, so the cache always returns fresh data to it,
|
|
// but a public flood (multiple requests/second) is served from RAM and never
|
|
// reaches Postgres. The cache is intentionally tiny — one entry — because the
|
|
// endpoint takes no parameters.
|
|
let cached: CachedResult | null = null
|
|
|
|
// Holds the pending check when one is in flight so concurrent cache misses
|
|
// share a single Postgres round-trip. Cleared as soon as the promise settles.
|
|
// Bounds the worst case to one DB query per CACHE_TTL_MS window regardless of
|
|
// burst arrival rate (e.g. a load-balancer replaying queued probes).
|
|
let pending: Promise<CheckResult> | null = null
|
|
|
|
/**
|
|
* GET /api/health
|
|
* Public health check endpoint (no auth required).
|
|
*
|
|
* Error details are logged server-side only — never echoed to the response
|
|
* body, which would expose Postgres error text on a public endpoint. The
|
|
* logger receives only error.code/error.message; raw Supabase error objects
|
|
* may include schema names, table names, or query fragments that should
|
|
* never reach application logs.
|
|
*
|
|
* Results are cached for {@link CACHE_TTL_MS} so flood traffic does not
|
|
* hammer Postgres with a service-role query per request.
|
|
*/
|
|
export async function GET() {
|
|
const now = Date.now()
|
|
if (cached && cached.expires > now) {
|
|
return NextResponse.json(cached.body, { status: cached.status })
|
|
}
|
|
|
|
const inFlight = pending ?? (pending = runAndCache(now))
|
|
try {
|
|
const result = await inFlight
|
|
return NextResponse.json(result.body, { status: result.status })
|
|
} finally {
|
|
if (pending === inFlight) pending = null
|
|
}
|
|
}
|
|
|
|
async function runAndCache(now: number): Promise<CheckResult> {
|
|
const result = await runHealthCheck()
|
|
cached = { ...result, expires: now + CACHE_TTL_MS }
|
|
return result
|
|
}
|
|
|
|
async function runHealthCheck(): Promise<CheckResult> {
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
|
|
if (!supabaseUrl || !supabaseServiceKey) {
|
|
log.error('Missing Supabase configuration for health check')
|
|
return {
|
|
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
|
|
status: 503,
|
|
}
|
|
}
|
|
|
|
try {
|
|
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
|
const { error } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id', { count: 'exact', head: true })
|
|
.limit(1)
|
|
|
|
if (error) {
|
|
// PostgrestError is a plain object: { code, message, details, hint }.
|
|
// details/hint can contain table or column names; log only the
|
|
// operationally useful fields.
|
|
log.error('Database health check failed', {
|
|
errCode: error.code ?? null,
|
|
errMessage: error.message ?? null,
|
|
})
|
|
return {
|
|
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
|
|
status: 503,
|
|
}
|
|
}
|
|
|
|
return {
|
|
body: { status: 'healthy', timestamp: new Date().toISOString(), version: '1.0.0' },
|
|
status: 200,
|
|
}
|
|
} catch (err) {
|
|
// Caught Error instances are reduced to {name, message, code} by the
|
|
// logger's redactor; never pass the raw value lest a deep stack containing
|
|
// query strings ends up in production logs.
|
|
const e = err as { name?: unknown; message?: unknown; code?: unknown }
|
|
log.error('Health check unexpected error', {
|
|
errName: typeof e?.name === 'string' ? e.name : null,
|
|
errMessage: typeof e?.message === 'string' ? e.message : null,
|
|
errCode: typeof e?.code === 'string' ? e.code : null,
|
|
})
|
|
return {
|
|
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
|
|
status: 503,
|
|
}
|
|
}
|
|
}
|