refactor(web): use valibot for runtime validation

Quentin Gliech and Claude Opus 4.6 (1M context) created

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) <noreply@anthropic.com>

Change summary

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(-)

Detailed changes

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",

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

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<typeof authUserSchema>;
 
 // '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))

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<typeof codePageSearchSchema>;
 
 type ViewMode = CodePageSearch["type"];
 
 export const Route = createFileRoute("/$repo/")({
   component: RouteComponent,
   pendingComponent: CodePageSkeleton,
-  validateSearch: (search: Record<string, unknown>): 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<RefsQueryData>(REFS_QUERY, {
       variables: { repo: repo === "_" ? null : repo },

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<string, unknown>): 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<string>(["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);
   }
 

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<typeof identityItemSchema>;
 
 function RouteComponent() {
   const [identities, setIdentities] = useState<IdentityItem[] | null>(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));