Files
HealthLog/src/lib/api-response.ts
T
Marc-André Bombeck 8399c47874 refactor(api): adopt assertMedicationOwnership + rate-limit option-bag across 9 medication routes
Hoist the ownership-check boilerplate that nine medication sub-routes
were each carrying inline into the shared route-guard helper, and align
the three W19 routes that hand-wrote the rate-limit header loop on the
new apiError option-bag form.

Routes touched:
- titration: shared guard + replaced parseDoseString with the shared
  parseDoseMgOrNull + replaced the inline GLP1_DRUGS reverse-lookup
  with findDrugIdByBrand.
- side-effects: shared guard + option-bag headers + dropped the
  client-supplied 'category' field per code-M6 (server derives via
  categoryForEntry).
- inventory: shared guard + option-bag headers.
- cadence: shared guard layered on top of the W21 Fix-O timezone wiring.
- intake (POST + GET): shared guard.
- compliance: shared guard.
- phase-config (GET + PUT + DELETE): shared guard.
- api-endpoint (GET + PUT): shared guard.
- glp1: shared guard replacing Fix-K's inline copy + option-bag headers.

apiError now accepts { headers: Record<string,string> } in its third
argument bag; the headers flow through NextResponse and are stripped
from the meta envelope so the existing { errorCode, ... } meta usage
keeps its shape.
2026-05-14 23:09:50 +02:00

121 lines
4.3 KiB
TypeScript

import { NextResponse } from "next/server";
export function apiSuccess<T>(data: T, status = 200) {
return NextResponse.json({ data, error: null }, { status });
}
/**
* `meta` is additive — clients that ignore it see the unchanged
* `{ data: null, error: <string> }` envelope. New callers can pass
* `{ errorCode: "credentials_rejected" }` so the UI translates the message
* via `t("settings.testConnection.errors." + errorCode)` instead of
* displaying the server's English fallback.
*
* v1.4.25 W21 Fix-N — the third argument may also carry a `headers`
* record (e.g. `{ headers: rateLimitHeaders(rl) }`) so 429 responses
* can attach the `X-RateLimit-*` triple in the same call. The `headers`
* key is *not* echoed into the JSON body — it is consumed by the
* NextResponse constructor and stripped from the meta envelope.
*/
export function apiError(
message: string,
status = 400,
meta?: {
errorCode?: string;
headers?: Record<string, string>;
} & Record<string, unknown>,
) {
const { headers, ...rest } = meta ?? {};
const metaKeys = Object.keys(rest);
return NextResponse.json(
{
data: null,
error: message,
...(metaKeys.length > 0 ? { meta: rest } : {}),
},
{
status,
...(headers ? { headers } : {}),
},
);
}
/**
* Safely parse JSON body from a request.
* Returns the parsed body or a 400 error response if parsing fails.
*/
export async function safeJson<T = unknown>(
request: Request,
): Promise<{ data: T; error?: never } | { data?: never; error: Response }> {
const ct = request.headers.get("content-type");
if (!ct || !ct.includes("application/json")) {
return { error: apiError("Content-Type must be application/json", 415) };
}
try {
const data = (await request.json()) as T;
return { data };
} catch {
return { error: apiError("Invalid JSON body", 400) };
}
}
/**
* Resolve the real client IP from a request, respecting trusted-proxy
* configuration. V3 audit: previously took the leftmost XFF entry blindly,
* letting a client rotate `X-Forwarded-For: 1.2.3.4` per request to defeat
* IP-based rate-limits.
*
* Trust model (`TRUST_PROXY_HOPS` env):
* - "0" → ignore XFF entirely, fall back to x-real-ip / null
* (use this if HealthLog is internet-facing without a
* reverse proxy you control)
* - "1" (default)→ trust exactly one hop (typical Coolify / Caddy /
* Cloudflare-Tunnel single-proxy deployment); read the
* rightmost XFF entry which is the IP your proxy
* observed when the request arrived.
* - "N" (>1) → trust N hops; read the Nth-from-rightmost XFF entry.
*
* Returns the resolved IP or null when no trusted source is available.
*/
function looksLikeIp(s: string): boolean {
return /^[0-9a-fA-F.:]+$/.test(s) && s.length >= 3 && s.length <= 45;
}
function parseTrustProxyHops(raw: string | undefined): number {
if (raw === undefined) return 1;
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) {
// Reject explicitly-invalid values so an operator typo doesn't silently
// switch a real-proxy deployment to "no XFF trust" mode (review
// finding L-1: TRUST_PROXY_HOPS=garbage degraded to hops=0 silently).
throw new Error(
`TRUST_PROXY_HOPS must be a non-negative integer, got: ${JSON.stringify(raw)}`,
);
}
return parseInt(trimmed, 10);
}
export function getClientIp(request: Request): string | null {
const hops = parseTrustProxyHops(process.env.TRUST_PROXY_HOPS);
if (hops > 0) {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const chain = forwarded
.split(",")
.map((s) => s.trim())
.filter(looksLikeIp);
// Review finding M-3: when the chain is shorter than the configured
// hops count, the operator misconfigured TRUST_PROXY_HOPS or a
// proxy was bypassed. Falling back to the leftmost (now
// attacker-controlled) entry would re-introduce the very rotation
// attack TRUST_PROXY_HOPS was meant to close. Refuse to read XFF.
if (chain.length >= hops) {
return chain[chain.length - hops];
}
}
}
const realIp = request.headers.get("x-real-ip");
return realIp && looksLikeIp(realIp) ? realIp : null;
}