From 8257431508c115c7a5555885e750996e58d8b1ea Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Sun, 29 Mar 2026 21:19:21 +0200 Subject: [PATCH] refactor(web): use valibot for runtime validation replace hand-rolled typeof guards and unsafe type assertions with valibot schemas for proper runtime validation: - validateSearch: use v.object + v.fallback + v.picklist for typed search param parsing with defaults (code page, issues page) - fetch().json(): validate auth user and identity responses against valibot schemas instead of unsafe `as` casts - derive TypeScript types from schemas via v.InferOutput Co-Authored-By: Claude Opus 4.6 (1M context) --- webui2/package.json | 3 ++- webui2/pnpm-lock.yaml | 15 +++++++++++++ webui2/src/lib/auth.tsx | 25 +++++++++++----------- webui2/src/routes/$repo/index.tsx | 25 ++++++++-------------- webui2/src/routes/$repo/issues/index.tsx | 22 +++++++++---------- webui2/src/routes/auth/select-identity.tsx | 22 ++++++++++--------- 6 files changed, 61 insertions(+), 51 deletions(-) diff --git a/webui2/package.json b/webui2/package.json index fdb605630f49816459bf05be8ba7b6041ae86fa7..d403d44112eb7f1c69471e3d21b88bb136ca2c22 100644 --- a/webui2/package.json +++ b/webui2/package.json @@ -36,7 +36,8 @@ "remark-gfm": "^4.0.0", "rxjs": "^7.8.2", "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "valibot": "^1.3.1" }, "devDependencies": { "@graphql-codegen/cli": "^6.2.1", diff --git a/webui2/pnpm-lock.yaml b/webui2/pnpm-lock.yaml index a7b94f501b64c7f8b9d6af411e58d63324052767..d08d33b59bec35f5a972c305502b35fbf6b2b4d5 100644 --- a/webui2/pnpm-lock.yaml +++ b/webui2/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + valibot: + specifier: ^1.3.1 + version: 1.3.1(typescript@6.0.2) devDependencies: '@graphql-codegen/cli': specifier: ^6.2.1 @@ -3618,6 +3621,14 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + valibot@1.3.1: + resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -7549,6 +7560,10 @@ snapshots: util-deprecate@1.0.2: {} + valibot@1.3.1(typescript@6.0.2): + optionalDependencies: + typescript: 6.0.2 + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/webui2/src/lib/auth.tsx b/webui2/src/lib/auth.tsx index 167dffff522fb3ac9bcd11c38e2a0530f7a58fcd..13f9011f9f4c1fdf42a784eeac74d7df95b7736a 100644 --- a/webui2/src/lib/auth.tsx +++ b/webui2/src/lib/auth.tsx @@ -17,19 +17,22 @@ import { gql } from "@apollo/client"; import { useQuery } from "@apollo/client/react"; import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import * as v from "valibot"; import { useServerConfigQuery } from "@/__generated__/graphql"; +const authUserSchema = v.object({ + id: v.string(), + humanId: v.string(), + name: v.nullable(v.string()), + displayName: v.string(), + avatarUrl: v.nullable(v.string()), + email: v.nullable(v.string()), + login: v.nullable(v.string()), +}); + // AuthUser matches the Identity type fields we care about for auth purposes. -export interface AuthUser { - id: string; - humanId: string; - name: string | null; - displayName: string; - avatarUrl: string | null; - email: string | null; - login: string | null; -} +export type AuthUser = v.InferOutput; // 'local' — single-user mode, identity from git config // 'external' — multi-user mode, identity from OAuth/OIDC session @@ -108,9 +111,7 @@ function ExternalAuthProvider({ .then(async (res) => { if (res.status === 401) return null; if (!res.ok) throw new Error(`/auth/user returned ${res.status}`); - // eslint-disable-next-line typescript-eslint/no-unsafe-assignment - const data: AuthUser = await res.json(); - return data; + return v.parse(authUserSchema, await res.json()); }) .then((u) => setUser(u)) .catch(() => setUser(null)) diff --git a/webui2/src/routes/$repo/index.tsx b/webui2/src/routes/$repo/index.tsx index 4e963d017cd74e45356ed70043cee751541784f8..05dd8d820d5c15c516fd0ec9ef9ddd4cc1639602 100644 --- a/webui2/src/routes/$repo/index.tsx +++ b/webui2/src/routes/$repo/index.tsx @@ -6,6 +6,7 @@ import { useQuery, useReadQuery } from "@apollo/client/react"; import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router"; import { AlertCircle, GitCommit } from "lucide-react"; import { useEffect } from "react"; +import * as v from "valibot"; import { GitObjectType, @@ -111,28 +112,20 @@ interface BlobQueryData { } | null; } -export type CodePageSearch = { - ref: string; - path: string; - type: "tree" | "blob" | "commits"; -}; +const codePageSearchSchema = v.object({ + ref: v.fallback(v.string(), ""), + path: v.fallback(v.string(), ""), + type: v.fallback(v.picklist(["tree", "blob", "commits"]), "tree"), +}); + +export type CodePageSearch = v.InferOutput; type ViewMode = CodePageSearch["type"]; export const Route = createFileRoute("/$repo/")({ component: RouteComponent, pendingComponent: CodePageSkeleton, - validateSearch: (search: Record): CodePageSearch => { - const ref = typeof search.ref === "string" ? search.ref : ""; - const path = typeof search.path === "string" ? search.path : ""; - const typeStr = typeof search.type === "string" ? search.type : ""; - const validTypes: ViewMode[] = ["tree", "blob", "commits"]; - return { - ref, - path, - type: validTypes.find((t) => t === typeStr) ?? "tree", - }; - }, + validateSearch: (search) => v.parse(codePageSearchSchema, search), loader: ({ params: { repo } }) => ({ refsRef: preloadQuery(REFS_QUERY, { variables: { repo: repo === "_" ? null : repo }, diff --git a/webui2/src/routes/$repo/issues/index.tsx b/webui2/src/routes/$repo/issues/index.tsx index 81e67f66f2e85a83b05e763469f60a8af69ce823..9daa1e9e7b977e9b231873638a15e0c347c80ca5 100644 --- a/webui2/src/routes/$repo/issues/index.tsx +++ b/webui2/src/routes/$repo/issues/index.tsx @@ -1,6 +1,7 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react"; import { useState } from "react"; +import * as v from "valibot"; import { useBugListQuery } from "@/__generated__/graphql"; import { BugRow } from "@/components/bugs/BugRow"; @@ -13,17 +14,14 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useRepo } from "@/lib/repo"; import { cn } from "@/lib/utils"; -type IssuesSearch = { - q: string; - after: string; -}; +const issuesSearchSchema = v.object({ + q: v.fallback(v.string(), "status:open"), + after: v.fallback(v.string(), ""), +}); export const Route = createFileRoute("/$repo/issues/")({ component: RouteComponent, - validateSearch: (search: Record): IssuesSearch => ({ - q: typeof search.q === "string" ? search.q : "status:open", - after: typeof search.after === "string" ? search.after : "", - }), + validateSearch: (search) => v.parse(issuesSearchSchema, search), }); const PAGE_SIZE = 25; @@ -314,8 +312,8 @@ function tokenizeQuery(input: string): string[] { // Parse a query string back into structured filter state. const VALID_SORTS = new Set(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]); -function isValidSort(v: string): v is SortValue { - return VALID_SORTS.has(v); +function isValidSort(val: string): val is SortValue { + return VALID_SORTS.has(val); } function parseQueryString(input: string): { @@ -337,8 +335,8 @@ function parseQueryString(input: string): { else if (token.startsWith("label:")) labels.push(token.slice(6)); else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, ""); else if (token.startsWith("sort:")) { - const v = token.slice(5); - if (isValidSort(v)) sort = v; + const val = token.slice(5); + if (isValidSort(val)) sort = val; } else free.push(token); } diff --git a/webui2/src/routes/auth/select-identity.tsx b/webui2/src/routes/auth/select-identity.tsx index 02972a72f50cd7f67beb03500554cfff72b4aa6c..bde24d4a07982b4e3613fa102f8d71de00e8ba67 100644 --- a/webui2/src/routes/auth/select-identity.tsx +++ b/webui2/src/routes/auth/select-identity.tsx @@ -9,6 +9,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { UserCircle, Plus, AlertCircle } from "lucide-react"; import { useEffect, useState } from "react"; +import * as v from "valibot"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; @@ -17,14 +18,16 @@ export const Route = createFileRoute("/auth/select-identity")({ component: RouteComponent, }); -interface IdentityItem { - repoSlug: string; - id: string; - humanId: string; - displayName: string; - login?: string; - avatarUrl?: string; -} +const identityItemSchema = v.object({ + repoSlug: v.string(), + id: v.string(), + humanId: v.string(), + displayName: v.string(), + login: v.optional(v.string()), + avatarUrl: v.optional(v.string()), +}); + +type IdentityItem = v.InferOutput; function RouteComponent() { const [identities, setIdentities] = useState(null); @@ -36,8 +39,7 @@ function RouteComponent() { try { const res = await fetch("/auth/identities", { credentials: "include" }); if (!res.ok) throw new Error(`unexpected status ${res.status}`); - // eslint-disable-next-line typescript-eslint/no-unsafe-assignment - const data: IdentityItem[] = await res.json(); + const data = v.parse(v.array(identityItemSchema), await res.json()); setIdentities(data); } catch (e) { setError(String(e));