fix(type-safety): replace manual typeguards with valibot schemas in SPA and reddit-fetch (#3313)

Replace all `as Record<string, unknown>` casts and manual multi-level
typeguard chains with proper valibot schema validation in:

- main.ts: Reddit token response, error parsing, jQuery comment URL extraction
- reddit-fetch.ts: Reddit auth, listing extraction, user comment fetching

Adds RedditTokenSchema, RedditListingSchema, RedditChildDataSchema, and
RedditCommentDataSchema with v.safeParse() for all external API data.

Closes #3200

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-04-16 12:17:15 -07:00 committed by GitHub
parent 513d3448d4
commit b290a3bb10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 79 additions and 50 deletions

View file

@ -10,6 +10,37 @@
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import * as v from "valibot";
/** Valibot schemas for Reddit API responses. */
const RedditTokenSchema = v.object({
access_token: v.string(),
});
const RedditChildDataSchema = v.looseObject({
name: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
title: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
permalink: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
subreddit: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
score: v.pipe(v.unknown(), v.transform((x) => Number(x ?? 0))),
num_comments: v.pipe(v.unknown(), v.transform((x) => Number(x ?? 0))),
created_utc: v.pipe(v.unknown(), v.transform((x) => Number(x ?? 0))),
selftext: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
author: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
});
const RedditListingSchema = v.object({
data: v.object({
children: v.array(v.object({
data: RedditChildDataSchema,
})),
}),
});
const RedditCommentDataSchema = v.looseObject({
body: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
subreddit: v.pipe(v.unknown(), v.transform((x) => String(x ?? ""))),
});
const CLIENT_ID = process.env.REDDIT_CLIENT_ID ?? ""; const CLIENT_ID = process.env.REDDIT_CLIENT_ID ?? "";
const CLIENT_SECRET = process.env.REDDIT_CLIENT_SECRET ?? ""; const CLIENT_SECRET = process.env.REDDIT_CLIENT_SECRET ?? "";
@ -156,13 +187,13 @@ async function getToken(): Promise<string> {
}, },
body: `grant_type=password&username=${encodeURIComponent(USERNAME)}&password=${encodeURIComponent(PASSWORD)}`, body: `grant_type=password&username=${encodeURIComponent(USERNAME)}&password=${encodeURIComponent(PASSWORD)}`,
}); });
const data = (await res.json()) as Record<string, unknown>; const json: unknown = await res.json();
const token = typeof data.access_token === "string" ? data.access_token : ""; const parsed = v.safeParse(RedditTokenSchema, json);
if (!token) { if (!parsed.success) {
console.error("Reddit auth failed:", JSON.stringify(data)); console.error("Reddit auth failed:", JSON.stringify(json));
process.exit(1); process.exit(1);
} }
return token; return parsed.output.access_token;
} }
/** Fetch a Reddit API endpoint with auth. */ /** Fetch a Reddit API endpoint with auth. */
@ -183,29 +214,23 @@ async function redditGet(token: string, path: string): Promise<unknown> {
/** Extract posts from a Reddit listing response. */ /** Extract posts from a Reddit listing response. */
function extractPosts(data: unknown): Map<string, RedditPost> { function extractPosts(data: unknown): Map<string, RedditPost> {
const posts = new Map<string, RedditPost>(); const posts = new Map<string, RedditPost>();
if (!data || typeof data !== "object") return posts; const parsed = v.safeParse(RedditListingSchema, data);
const listing = data as Record<string, unknown>; if (!parsed.success) return posts;
const listingData = listing.data as Record<string, unknown> | undefined;
const children = listingData?.children;
if (!Array.isArray(children)) return posts;
for (const child of children) { for (const child of parsed.output.data.children) {
const c = child as Record<string, unknown>; const d = child.data;
const d = c.data as Record<string, unknown> | undefined; if (!d.name || posts.has(d.name)) continue;
if (!d) continue;
const id = String(d.name ?? "");
if (!id || posts.has(id)) continue;
posts.set(id, { posts.set(d.name, {
title: String(d.title ?? ""), title: d.title,
permalink: String(d.permalink ?? ""), permalink: d.permalink,
subreddit: String(d.subreddit ?? ""), subreddit: d.subreddit,
postId: id, postId: d.name,
score: Number(d.score ?? 0), score: d.score,
numComments: Number(d.num_comments ?? 0), numComments: d.num_comments,
createdUtc: Number(d.created_utc ?? 0), createdUtc: d.created_utc,
selftext: String(d.selftext ?? "").slice(0, 2000), selftext: d.selftext.slice(0, 2000),
authorName: String(d.author ?? ""), authorName: d.author,
authorComments: [], authorComments: [],
}); });
} }
@ -221,18 +246,15 @@ async function fetchUserComments(token: string, username: string): Promise<strin
// depth. // depth.
if (!REDDIT_USERNAME_RE.test(username)) return []; if (!REDDIT_USERNAME_RE.test(username)) return [];
const data = await redditGet(token, `/user/${encodeURIComponent(username)}/comments?limit=25&sort=new`); const data = await redditGet(token, `/user/${encodeURIComponent(username)}/comments?limit=25&sort=new`);
if (!data || typeof data !== "object") return []; const parsed = v.safeParse(RedditListingSchema, data);
const listing = data as Record<string, unknown>; if (!parsed.success) return [];
const listingData = listing.data as Record<string, unknown> | undefined;
const children = listingData?.children;
if (!Array.isArray(children)) return [];
return children return parsed.output.data.children
.map((child) => { .map((child) => {
const c = child as Record<string, unknown>; const cp = v.safeParse(RedditCommentDataSchema, child.data);
const d = c.data as Record<string, unknown> | undefined; if (!cp.success) return "";
const body = String(d?.body ?? "").slice(0, 500); const body = cp.output.body.slice(0, 500);
const sub = String(d?.subreddit ?? ""); const sub = cp.output.subreddit;
return sub ? `[r/${sub}] ${body}` : body; return sub ? `[r/${sub}] ${body}` : body;
}) })
.filter(Boolean); .filter(Boolean);

View file

@ -1549,8 +1549,9 @@ async function getRedditToken(): Promise<string | null> {
}, },
body: `grant_type=password&username=${encodeURIComponent(REDDIT_USERNAME)}&password=${encodeURIComponent(REDDIT_PASSWORD)}`, body: `grant_type=password&username=${encodeURIComponent(REDDIT_USERNAME)}&password=${encodeURIComponent(REDDIT_PASSWORD)}`,
}); });
const data = (await res.json()) as Record<string, unknown>; const json: unknown = await res.json();
return typeof data.access_token === "string" ? data.access_token : null; const parsed = v.safeParse(v.object({ access_token: v.string() }), json);
return parsed.success ? parsed.output.access_token : null;
} }
/** Post a reply to a Reddit thread. Returns the comment URL or an error. */ /** Post a reply to a Reddit thread. Returns the comment URL or an error. */
@ -1578,10 +1579,11 @@ async function postRedditReply(postId: string, replyText: string): Promise<Respo
body: `thing_id=${encodeURIComponent(postId)}&text=${encodeURIComponent(replyText)}`, body: `thing_id=${encodeURIComponent(postId)}&text=${encodeURIComponent(replyText)}`,
}); });
const data = (await res.json()) as Record<string, unknown>; const json: unknown = await res.json();
if (!res.ok) { if (!res.ok) {
const errMsg = typeof data.message === "string" ? data.message : `HTTP ${res.status}`; const errParsed = v.safeParse(v.object({ message: v.string() }), json);
const errMsg = errParsed.success ? errParsed.output.message : `HTTP ${res.status}`;
console.error(`[spa] Reddit reply failed: ${errMsg}`); console.error(`[spa] Reddit reply failed: ${errMsg}`);
return Response.json( return Response.json(
{ {
@ -1594,19 +1596,24 @@ async function postRedditReply(postId: string, replyText: string): Promise<Respo
); );
} }
// Extract the comment URL from the response // Reddit's legacy "comment" endpoint returns a jQuery-style response.
const jquery = data.jquery as unknown[] | undefined; // Extract the permalink from nested arrays: jquery[n][3][m].data.permalink
const JqueryCommentSchema = v.object({
jquery: v.array(v.unknown()),
});
const JqueryInnerSchema = v.object({
data: v.object({ permalink: v.string() }),
});
let commentUrl = ""; let commentUrl = "";
if (Array.isArray(jquery)) { const jqParsed = v.safeParse(JqueryCommentSchema, json);
for (const item of jquery) { if (jqParsed.success) {
for (const item of jqParsed.output.jquery) {
if (Array.isArray(item) && item.length >= 4 && Array.isArray(item[3])) { if (Array.isArray(item) && item.length >= 4 && Array.isArray(item[3])) {
for (const inner of item[3]) { for (const inner of item[3]) {
const rec = inner as Record<string, unknown> | undefined; const innerParsed = v.safeParse(JqueryInnerSchema, inner);
if (rec && typeof rec.data === "object" && rec.data !== null) { if (innerParsed.success) {
const d = rec.data as Record<string, unknown>; commentUrl = `https://reddit.com${innerParsed.output.data.permalink}`;
if (typeof d.permalink === "string") {
commentUrl = `https://reddit.com${d.permalink}`;
}
} }
} }
} }