Tool text that lied to agents: - gnubok_create_voucher pointed at gnubok_reverse_entry, which does not exist; the tool is gnubok_reverse_journal_entry. A scan of server.ts, skills/, prompts/ and structured-errors.ts found no other phantom names. - gnubok_reverse_journal_entry said reversal_date defaults to today; the executor passes undefined and reverseEntry() uses the original entry date (same as the dashboard). Description now states that. No behaviour change. - gnubok_get_vacation_balance promised an estimated semesterloneskuld in SEK but returned none. The tool now returns estimated_liability_sek using the same BFNAR 2016:10 day valuation as the year-close and the v1 vacation-balance route (dayValueSek exported from semesterberedning), floored at zero for overdrawn balances. Descriptions trimmed so the tools/list payload stays under the 60.7K-token ceiling (60,696 after). - gnubok_create_invoice said the invoice number is assigned at approval; it is assigned on send or mark-as-sent (ensureInvoiceNumber). - gnubok_convert_invoice: "har redan makuleras" -> "har redan makulerats". - lib/entitlements/keys.ts comment claimed bank_sync has no MCP tool while the map right below gates gnubok_connect_bank on it. Version: MCP serverInfo.version, the extension version and /api/health all hardcoded '1.0.0', so clients could not tell deploys apart. They now share currentAppVersion() (commit SHA prefix inlined at build), resolved once at module load so the definitions layer stays deterministic, with '1.0.0' as the self-hosted fallback so Docker healthchecks keep a value. serverInfo is not part of tools/list, so the catalog payload is unaffected by this part. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
129 lines
4.5 KiB
TypeScript
129 lines
4.5 KiB
TypeScript
import { createServiceRoleClient } from '@/lib/supabase/service-client'
|
|
import { NextResponse } from 'next/server'
|
|
import { createLogger } from '@/lib/logger'
|
|
import { currentAppVersion } from '@/lib/reports/app-version'
|
|
|
|
const log = createLogger('health')
|
|
|
|
// Build identifier (commit SHA prefix on Vercel) so operators can tell which
|
|
// deploy answered. Falls back to '1.0.0' for self-hosted builds with no SHA
|
|
// inlined, so Docker healthchecks keep a stable value. Same identifier the
|
|
// MCP serverInfo and the behandlingshistorik report stamp.
|
|
const APP_VERSION = currentAppVersion() ?? '1.0.0'
|
|
|
|
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: APP_VERSION },
|
|
status: 503,
|
|
}
|
|
}
|
|
|
|
try {
|
|
const supabase = createServiceRoleClient(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: APP_VERSION },
|
|
status: 503,
|
|
}
|
|
}
|
|
|
|
return {
|
|
body: { status: 'healthy', timestamp: new Date().toISOString(), version: APP_VERSION },
|
|
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: APP_VERSION },
|
|
status: 503,
|
|
}
|
|
}
|
|
}
|