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 { 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_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)}`,
});
const data = (await res.json()) as Record<string, unknown>;
const token = typeof data.access_token === "string" ? data.access_token : "";
if (!token) {
console.error("Reddit auth failed:", JSON.stringify(data));
const json: unknown = await res.json();
const parsed = v.safeParse(RedditTokenSchema, json);
if (!parsed.success) {
console.error("Reddit auth failed:", JSON.stringify(json));
process.exit(1);
}
return token;
return parsed.output.access_token;
}
/** 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. */
function extractPosts(data: unknown): Map<string, RedditPost> {
const posts = new Map<string, RedditPost>();
if (!data || typeof data !== "object") return posts;
const listing = data as Record<string, unknown>;
const listingData = listing.data as Record<string, unknown> | undefined;
const children = listingData?.children;
if (!Array.isArray(children)) return posts;
const parsed = v.safeParse(RedditListingSchema, data);
if (!parsed.success) return posts;
for (const child of children) {
const c = child as Record<string, unknown>;
const d = c.data as Record<string, unknown> | undefined;
if (!d) continue;
const id = String(d.name ?? "");
if (!id || posts.has(id)) continue;
for (const child of parsed.output.data.children) {
const d = child.data;
if (!d.name || posts.has(d.name)) continue;
posts.set(id, {
title: String(d.title ?? ""),
permalink: String(d.permalink ?? ""),
subreddit: String(d.subreddit ?? ""),
postId: id,
score: Number(d.score ?? 0),
numComments: Number(d.num_comments ?? 0),
createdUtc: Number(d.created_utc ?? 0),
selftext: String(d.selftext ?? "").slice(0, 2000),
authorName: String(d.author ?? ""),
posts.set(d.name, {
title: d.title,
permalink: d.permalink,
subreddit: d.subreddit,
postId: d.name,
score: d.score,
numComments: d.num_comments,
createdUtc: d.created_utc,
selftext: d.selftext.slice(0, 2000),
authorName: d.author,
authorComments: [],
});
}
@ -221,18 +246,15 @@ async function fetchUserComments(token: string, username: string): Promise<strin
// depth.
if (!REDDIT_USERNAME_RE.test(username)) return [];
const data = await redditGet(token, `/user/${encodeURIComponent(username)}/comments?limit=25&sort=new`);
if (!data || typeof data !== "object") return [];
const listing = data as Record<string, unknown>;
const listingData = listing.data as Record<string, unknown> | undefined;
const children = listingData?.children;
if (!Array.isArray(children)) return [];
const parsed = v.safeParse(RedditListingSchema, data);
if (!parsed.success) return [];
return children
return parsed.output.data.children
.map((child) => {
const c = child as Record<string, unknown>;
const d = c.data as Record<string, unknown> | undefined;
const body = String(d?.body ?? "").slice(0, 500);
const sub = String(d?.subreddit ?? "");
const cp = v.safeParse(RedditCommentDataSchema, child.data);
if (!cp.success) return "";
const body = cp.output.body.slice(0, 500);
const sub = cp.output.subreddit;
return sub ? `[r/${sub}] ${body}` : body;
})
.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)}`,
});
const data = (await res.json()) as Record<string, unknown>;
return typeof data.access_token === "string" ? data.access_token : null;
const json: unknown = await res.json();
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. */
@ -1578,10 +1579,11 @@ async function postRedditReply(postId: string, replyText: string): Promise<Respo
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) {
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}`);
return Response.json(
{
@ -1594,19 +1596,24 @@ async function postRedditReply(postId: string, replyText: string): Promise<Respo
);
}
// Extract the comment URL from the response
const jquery = data.jquery as unknown[] | undefined;
// Reddit's legacy "comment" endpoint returns a jQuery-style response.
// 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 = "";
if (Array.isArray(jquery)) {
for (const item of jquery) {
const jqParsed = v.safeParse(JqueryCommentSchema, json);
if (jqParsed.success) {
for (const item of jqParsed.output.jquery) {
if (Array.isArray(item) && item.length >= 4 && Array.isArray(item[3])) {
for (const inner of item[3]) {
const rec = inner as Record<string, unknown> | undefined;
if (rec && typeof rec.data === "object" && rec.data !== null) {
const d = rec.data as Record<string, unknown>;
if (typeof d.permalink === "string") {
commentUrl = `https://reddit.com${d.permalink}`;
}
const innerParsed = v.safeParse(JqueryInnerSchema, inner);
if (innerParsed.success) {
commentUrl = `https://reddit.com${innerParsed.output.data.permalink}`;
}
}
}