← 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;
}