+ {/* Excluded reviews are set apart by background and border,
+ not opacity, which dropped their text below 4.5:1
+ (DOS/Rate#811). */}
diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts
index ef1a57e07..2ebdfdf7f 100644
--- a/apps/web/src/lib/rate-api.ts
+++ b/apps/web/src/lib/rate-api.ts
@@ -100,6 +100,11 @@ export function getRateClient(): RateSchemaClient {
return _client;
}
+/** Tests only: drop the cached client so the next call reads the env again. */
+export function resetRateClientForTests(): void {
+ _client = null;
+}
+
/**
* PostgREST caps every response at the project's max-rows (1000 by default) and
* silently drops the rest, so unbounded table reads must be paged. Pages advance by
diff --git a/apps/web/src/server/health.ts b/apps/web/src/server/health.ts
new file mode 100644
index 000000000..921727966
--- /dev/null
+++ b/apps/web/src/server/health.ts
@@ -0,0 +1,73 @@
+import { getRateClient } from '@/lib/rate-api';
+import { createLogger } from '@/lib/logger';
+
+const log = createLogger('health');
+
+/** A probe that takes longer than this counts as a failure. */
+export const DATABASE_CHECK_TIMEOUT_MS = 3_000;
+/**
+ * /api/health is public: one probe result is reused for this long per
+ * instance, so the endpoint cannot be used to load the database.
+ */
+export const DATABASE_CHECK_TTL_MS = 15_000;
+
+export type CheckStatus = 'ok' | 'error';
+
+let cached: { status: CheckStatus; at: number } | null = null;
+let inFlight: Promise | null = null;
+
+async function probeDatabase(): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), DATABASE_CHECK_TIMEOUT_MS);
+ try {
+ // The pages' own client and key: a disabled or rotated key fails here
+ // exactly as it fails for every uncached page (DOS/Rate#814).
+ const { error } = await getRateClient()
+ .from('categories')
+ .select('id')
+ .limit(1)
+ .abortSignal(controller.signal);
+ if (error) {
+ // postgrest-js retries a network error until the abort, so keep the
+ // cause next to the timeout flag.
+ log.error('database check failed', {
+ message: error.message,
+ timedOut: controller.signal.aborted,
+ });
+ return 'error';
+ }
+ return 'ok';
+ } catch (err) {
+ log.error('database check failed', {
+ message: err instanceof Error ? err.message : String(err),
+ timedOut: controller.signal.aborted,
+ });
+ return 'error';
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/**
+ * Whether the app can read the database with its own client. Concurrent
+ * callers share one probe, and a result is reused for DATABASE_CHECK_TTL_MS.
+ */
+export async function checkDatabase(now = Date.now()): Promise {
+ if (cached && now - cached.at < DATABASE_CHECK_TTL_MS) return cached.status;
+ inFlight ??= probeDatabase()
+ .catch((): CheckStatus => 'error')
+ .then((status) => {
+ cached = { status, at: Date.now() };
+ return status;
+ })
+ .finally(() => {
+ inFlight = null;
+ });
+ return inFlight;
+}
+
+/** Tests only: forget the cached probe result. */
+export function resetHealthCacheForTests(): void {
+ cached = null;
+ inFlight = null;
+}
diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts
index 106e471b8..c6f9fec67 100644
--- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts
+++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts
@@ -2319,9 +2319,57 @@ async function runCoreArchitectureSuite() {
'getMerchantsList must scope products by merchantIds and only query reviews for unscoredProductIds (C2)'
);
+ // /api/health reads the database (#829): stub PostgREST and the public env.
+ const healthMod = require('../../health') as typeof import('../../health');
+ const rateApiMod829 =
+ require('../../../lib/rate-api') as typeof import('../../../lib/rate-api');
+ const withHealthDatabase = async (
+ respond: (url: string, init?: RequestInit) => Promise,
+ run: () => Promise
+ ): Promise => {
+ const urlEnv = ['NEXT_PUBLIC_SUPABASE', 'URL'].join('_');
+ const anonEnv = ['NEXT_PUBLIC_SUPABASE', 'ANON', 'KEY'].join('_');
+ const prevUrl = process.env[urlEnv];
+ const prevAnon = process.env[anonEnv];
+ const prevFetch = globalThis.fetch;
+ process.env[urlEnv] = prevUrl || 'https://stub.supabase.test';
+ process.env[anonEnv] = prevAnon || 'stub-public-token';
+ healthMod.resetHealthCacheForTests();
+ // The client is cached; it must not outlive the stub env and point later
+ // guards at the stub host.
+ rateApiMod829.resetRateClientForTests();
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) =>
+ respond(
+ typeof input === 'string'
+ ? input
+ : input instanceof URL
+ ? input.toString()
+ : input.url,
+ init
+ )) as typeof fetch;
+ try {
+ return await run();
+ } finally {
+ globalThis.fetch = prevFetch;
+ if (prevUrl === undefined) delete process.env[urlEnv];
+ else process.env[urlEnv] = prevUrl;
+ if (prevAnon === undefined) delete process.env[anonEnv];
+ else process.env[anonEnv] = prevAnon;
+ healthMod.resetHealthCacheForTests();
+ rateApiMod829.resetRateClientForTests();
+ }
+ };
+ const healthyDatabase = async () =>
+ new Response('[{"id":"cat-1"}]', {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+
const healthRoute =
require('../../../app/api/health/route') as typeof import('../../../app/api/health/route');
- const healthRes = await healthRoute.GET();
+ const healthRes = await withHealthDatabase(healthyDatabase, () =>
+ healthRoute.GET()
+ );
assert.equal(healthRes.status, 200);
const healthBody = (await healthRes.json()) as {
status: string;
@@ -3454,7 +3502,9 @@ async function runCoreArchitectureSuite() {
const statsRoute = await import('../../../app/api/v1/rate/stats/route');
// 60.1 /api/health
- const healthRes60 = await healthRoute60.GET();
+ const healthRes60 = await withHealthDatabase(healthyDatabase, () =>
+ healthRoute60.GET()
+ );
assert.equal(healthRes60.status, 200);
assert.equal(healthRes60.headers.get('Cache-Control'), 'no-store, max-age=0');
const healthBody60 = (await healthRes60.json()) as {
@@ -8296,6 +8346,131 @@ async function runCoreArchitectureSuite() {
' ✅ [93/93] A bait price is never endorsed by the extension or the radar (#797) passed'
);
+ // 94. /api/health reports a database the app cannot read (#829)
+ {
+ const route829 = await import('../../../app/api/health/route');
+ type Health829 = {
+ status: string;
+ service: string;
+ checks: { database: string };
+ };
+ let probes829 = 0;
+ const json829 = (status: number, body: unknown) =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+ // OK: one small read of the rate schema with the app's client.
+ await withHealthDatabase(
+ async (url) => {
+ probes829++;
+ assert.match(url, /\/rest\/v1\/categories\?select=id&limit=1$/);
+ return json829(200, [{ id: 'cat-1' }]);
+ },
+ async () => {
+ const res = await route829.GET();
+ assert.equal(res.status, 200);
+ const body = (await res.json()) as Health829;
+ assert.equal(body.status, 'ok');
+ assert.deepEqual(body.checks, { database: 'ok' });
+ assert.equal(res.headers.get('Cache-Control'), 'no-store, max-age=0');
+ }
+ );
+ assert.equal(probes829, 1);
+
+ // The 2026-10-24 outage: PostgREST refuses the key. 503, no detail.
+ const outage829 = async (run: () => Promise) =>
+ withHealthDatabase(async () => {
+ probes829++;
+ return json829(401, {
+ message: 'Legacy API keys are disabled',
+ hint: 'stub',
+ });
+ }, run);
+ probes829 = 0;
+ await outage829(async () => {
+ const res = await route829.GET();
+ assert.equal(res.status, 503);
+ const text = await res.text();
+ assert.ok(!text.includes('Legacy API keys'), 'the cause is not returned');
+ const body = JSON.parse(text) as Health829;
+ assert.equal(body.status, 'degraded');
+ assert.equal(body.service, 'rate-web');
+ assert.deepEqual(body.checks, { database: 'error' });
+ assert.equal(res.headers.get('Cache-Control'), 'no-store, max-age=0');
+ // Within the TTL the result is reused: no second probe.
+ assert.equal((await route829.GET()).status, 503);
+ assert.equal(probes829, 1);
+ // After the TTL the database is probed again.
+ assert.equal(
+ await healthMod.checkDatabase(
+ Date.now() + healthMod.DATABASE_CHECK_TTL_MS + 1
+ ),
+ 'error'
+ );
+ assert.equal(probes829, 2);
+ });
+
+ // Concurrent requests share one probe.
+ probes829 = 0;
+ await withHealthDatabase(
+ async () => {
+ probes829++;
+ await new Promise((r) => setTimeout(r, 20));
+ return json829(200, []);
+ },
+ async () => {
+ const all = await Promise.all([
+ route829.GET(),
+ route829.GET(),
+ route829.GET(),
+ ]);
+ assert.deepEqual(
+ all.map((r) => r.status),
+ [200, 200, 200]
+ );
+ assert.equal(probes829, 1);
+ }
+ );
+
+ // A network failure is an error, not a crash. postgrest-js retries it
+ // until the timeout aborts the probe, so this also takes about 3 s.
+ await withHealthDatabase(
+ async () => {
+ throw new TypeError('fetch failed');
+ },
+ async () => {
+ assert.equal((await route829.GET()).status, 503);
+ }
+ );
+
+ // A database that never answers fails at the timeout.
+ await withHealthDatabase(
+ (_url, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () =>
+ reject(new DOMException('aborted', 'AbortError'))
+ );
+ }),
+ async () => {
+ const started = Date.now();
+ const res = await route829.GET();
+ const took = Date.now() - started;
+ assert.equal(res.status, 503);
+ assert.ok(
+ took >= healthMod.DATABASE_CHECK_TIMEOUT_MS - 50 &&
+ took < healthMod.DATABASE_CHECK_TIMEOUT_MS + 2000,
+ `timed out after ${took} ms`
+ );
+ }
+ );
+ }
+
+ console.log(
+ ' ✅ [94/94] /api/health reports a database the app cannot read (#829) passed'
+ );
+
console.log('🎉 All Rate.vn Core Architecture tests passed!');
}
diff --git a/apps/web/src/server/rate/openapi.ts b/apps/web/src/server/rate/openapi.ts
index 63441f832..6b4ddfa2f 100644
--- a/apps/web/src/server/rate/openapi.ts
+++ b/apps/web/src/server/rate/openapi.ts
@@ -1470,11 +1470,16 @@ export function buildRateOpenApiSpec(
operationId: 'getHealthStatus',
summary: 'Liveness & readiness health check probe',
description:
- 'Returns service liveness metadata (`status: "ok"`, `service: "rate-web"`, `version`, and ISO-8601 `timestamp`) with `Cache-Control: no-store, max-age=0` for load balancers and uptime monitors.',
+ 'Returns service metadata (`status`, `service: "rate-web"`, `version`, ISO-8601 `timestamp`) and `checks.database`, the result of a small read with the app\'s own database client (3 s timeout, reused for up to 15 s per instance), with `Cache-Control: no-store, max-age=0` for load balancers and uptime monitors.',
tags: ['Platform Telemetry'],
responses: {
'200': {
- description: 'Service is healthy and responding.',
+ description:
+ 'Service is healthy: `status: "ok"`, `checks.database: "ok"`.',
+ },
+ '503': {
+ description:
+ 'The database cannot be read: `status: "degraded"`, `checks.database: "error"`. The cause is logged server-side, not returned.',
},
},
},