Files
accounted/app/api/health/route.ts
T
1185ab4294 fix(mcp): honest tool text and build-derived server version (#1923)
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>
2026-08-26 13:35:15 +02:00

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,
}
}
}