← back to Govarbitrage
apps/mobile/lib/validate.ts
96 lines
/**
* Runtime payload validation for the API boundary (TK-10279, Cycle 6).
*
* Replaces the blind `as T` cast in api.ts: the server is trusted for TYPES at
* compile time only, so a malformed/garbage payload used to flow straight into
* the UI and crash it (a non-array `scores` → `.map` throw; a non-string
* `title` → `.slice` throw; NaN/Infinity numbers → absurd renders). This is the
* ROOT the render-layer guards (fmt helpers, pnlColor, scoreColor) were
* treating symptomatically.
*
* Scope (tight, per DTD): own what the render guards CANNOT —
* - structural faults (required arrays/objects/strings) that cause crashes,
* - non-finite / non-number values → null (so the existing guards show "—").
* It does NOT clamp implausible-but-finite numbers (e.g. annualizedReturn
* 23002764) — that stays fmtPct's job, by design.
*/
import type { ListingDetail, ListingsResponse, Tier } from "./types";
import { ApiError } from "./api-error";
// Recursively replace non-finite numbers (NaN/Infinity) with null, everywhere.
// Leaves finite numbers, strings, arrays, and object structure intact.
function sanitizeNonFinite(v: unknown): unknown {
if (typeof v === "number") return Number.isFinite(v) ? v : null;
if (Array.isArray(v)) return v.map(sanitizeNonFinite);
if (v && typeof v === "object") {
const out: Record<string, unknown> = {};
for (const k of Object.keys(v as Record<string, unknown>)) {
out[k] = sanitizeNonFinite((v as Record<string, unknown>)[k]);
}
return out;
}
return v;
}
const asObj = (v: unknown): Record<string, unknown> =>
v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
const asString = (v: unknown, fallback = ""): string => (typeof v === "string" ? v : fallback);
const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
/** GET /api/listings — guarantees rows is an array of shape-safe rows. */
export function normalizeListingsResponse(raw: unknown): ListingsResponse {
if (!raw || typeof raw !== "object" || !Array.isArray((raw as { rows?: unknown }).rows)) {
throw new ApiError(0, "Malformed response from server (expected a listings payload).");
}
const o = sanitizeNonFinite(raw) as Record<string, unknown>;
const rows = asArr(o.rows).map((r) => {
const row = asObj(r);
// guarantee the string fields that hit ad-hoc string ops downstream
row.id = asString(row.id);
row.title = asString(row.title);
return row;
});
return {
...o,
rows,
total: typeof o.total === "number" ? o.total : rows.length,
page: typeof o.page === "number" ? o.page : 1,
pageSize: typeof o.pageSize === "number" ? o.pageSize : rows.length,
tier: asString(o.tier, "FREE") as Tier,
gated: !!o.gated,
} as unknown as ListingsResponse;
}
/** GET /api/listings/:id — guarantees id/title strings + the mapped arrays. */
export function normalizeListingDetail(raw: unknown): ListingDetail {
if (!raw || typeof raw !== "object" || typeof (raw as { id?: unknown }).id !== "string") {
throw new ApiError(0, "Malformed response from server (expected a listing detail).");
}
const o = sanitizeNonFinite(raw) as Record<string, unknown>;
return {
...o,
id: asString(o.id),
title: asString(o.title),
// arrays the detail screen .map()s over — never let them be non-arrays
imageUrls: asArr(o.imageUrls).filter((u): u is string => typeof u === "string"),
// guard ITEM shape too: a non-string sc.profile crashes sc.profile.replace()
// in the detail screen — arrays-are-arrays isn't enough.
scores: asArr(o.scores).map((s) => {
const so = asObj(s);
so.profile = asString(so.profile);
so.explanation = asString(so.explanation);
return so;
}),
comparables: asArr(o.comparables).map((c) => {
const co = asObj(c);
co.title = asString(co.title);
co.kind = asString(co.kind);
co.source = co.source == null ? null : asString(co.source);
return co;
}),
// nested objects the screen reads with `cb.x` / `r.x` — object or null, never a scalar
research: o.research && typeof o.research === "object" ? o.research : null,
costBreakdown: o.costBreakdown && typeof o.costBreakdown === "object" ? o.costBreakdown : null,
} as unknown as ListingDetail;
}