From 5469337db3d11a754592a21d9a09a1f44b7108b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:05:06 +0000 Subject: [PATCH 1/3] Initial plan From 2d6a4d914cb5562d84735e862aaa89fa2dcc3488 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:19:53 +0000 Subject: [PATCH 2/3] feat: add Zod validation to server actions and API chat route - Replace `interface MarkdownSection`, `interface PagePath` in docs.ts with Zod schemas + `z.output` types - Add `ReplacedRangeSchema` and `DynamicMarkdownSectionSchema` to docs.ts, moving the type definitions out of the client-only pageContent.tsx - Remove `interface ReplacedRange` from multiHighlight.tsx; re-export `ReplacedRange` type from @/lib/docs - Remove `interface DynamicMarkdownSection` from pageContent.tsx; re-export `DynamicMarkdownSection` type from @/lib/docs - Replace `ReplOutputType` union, `ReplOutput`, `UpdatedFile`, `ReplCommand` interfaces in packages/runtime/src/interface.ts with Zod schemas + `z.output` types; add zod dependency to runtime package - Replace `type ChatParams` in route.ts with `ChatParamsSchema` + Zod validation of POST request body (returns 400 on invalid input) - Add `z.string().uuid()` validation to deleteChat and getRedirectFromChat server action parameters Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- .../@docs/[lang]/[pageId]/pageContent.tsx | 17 +------ app/actions/deleteChat.ts | 9 +++- app/actions/getRedirectFromChat.ts | 9 +++- app/api/chat/route.ts | 39 ++++++++++----- app/lib/docs.ts | 48 ++++++++++++++----- app/markdown/multiHighlight.tsx | 7 +-- package-lock.json | 3 +- packages/runtime/package.json | 3 +- packages/runtime/src/interface.ts | 48 +++++++++++-------- 9 files changed, 114 insertions(+), 69 deletions(-) diff --git a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx index 2e54858b..1d60f485 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx @@ -7,32 +7,19 @@ import { useSidebarMdContext } from "@/sidebar"; import clsx from "clsx"; import { PageTransition } from "./pageTransition"; import { + DynamicMarkdownSection, LanguageEntry, MarkdownSection, PageEntry, PagePath, SectionId, } from "@/lib/docs"; -import { ReplacedRange } from "@/markdown/multiHighlight"; import { Heading } from "@/markdown/heading"; import Link from "next/link"; import { useChatId } from "@/(docs)/chatAreaState"; import { ChatWithMessages } from "@/lib/chatHistory"; -/** - * MarkdownSectionに追加で、動的な情報を持たせる - */ -export interface DynamicMarkdownSection extends MarkdownSection { - /** - * ユーザーが今そのセクションを読んでいるかどうか - */ - inView: boolean; - /** - * チャットの会話を元にAIが書き換えた後の内容 - */ - replacedContent: string; - replacedRange: ReplacedRange[]; -} +export type { DynamicMarkdownSection }; interface PageContentProps { splitMdContent: MarkdownSection[]; diff --git a/app/actions/deleteChat.ts b/app/actions/deleteChat.ts index 32ce131f..492a4a33 100644 --- a/app/actions/deleteChat.ts +++ b/app/actions/deleteChat.ts @@ -1,8 +1,15 @@ "use server"; +import { z } from "zod"; import { deleteChat, initContext } from "@/lib/chatHistory"; +const chatIdSchema = z.string().uuid(); + export async function deleteChatAction(chatId: string) { + const parsed = chatIdSchema.safeParse(chatId); + if (!parsed.success) { + throw new Error(parsed.error.issues.map((e) => e.message).join(", ")); + } const ctx = await initContext(); - await deleteChat(chatId, ctx); + await deleteChat(parsed.data, ctx); } diff --git a/app/actions/getRedirectFromChat.ts b/app/actions/getRedirectFromChat.ts index 7e9be601..669b1560 100644 --- a/app/actions/getRedirectFromChat.ts +++ b/app/actions/getRedirectFromChat.ts @@ -1,18 +1,25 @@ "use server"; +import { z } from "zod"; import { initContext } from "@/lib/chatHistory"; import { LangId, PageSlug } from "@/lib/docs"; import { chat, section } from "@/schema/chat"; import { and, eq } from "drizzle-orm"; +const chatIdSchema = z.string().uuid(); + export async function getRedirectFromChat(chatId: string): Promise { + const parsed = chatIdSchema.safeParse(chatId); + if (!parsed.success) { + throw new Error(parsed.error.issues.map((e) => e.message).join(", ")); + } const { drizzle, userId } = await initContext(); if (!userId) { throw new Error("Not authenticated"); } const chatData = (await drizzle.query.chat.findFirst({ - where: and(eq(chat.chatId, chatId), eq(chat.userId, userId)), + where: and(eq(chat.chatId, parsed.data), eq(chat.userId, userId)), with: { section: true, }, diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index e37708a1..064ca030 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -6,18 +6,26 @@ import { CreateChatDiff, initContext, } from "@/lib/chatHistory"; -import { getPagesList, introSectionId, PagePath, SectionId } from "@/lib/docs"; -import { DynamicMarkdownSection } from "@/(docs)/@docs/[lang]/[pageId]/pageContent"; -import { ReplCommand, ReplOutput } from "@my-code/runtime/interface"; +import { + DynamicMarkdownSectionSchema, + getPagesList, + introSectionId, + PagePathSchema, + SectionId, +} from "@/lib/docs"; +import { ReplCommandSchema, ReplOutputSchema } from "@my-code/runtime/interface"; +import { z } from "zod"; + +const ChatParamsSchema = z.object({ + path: PagePathSchema, + userQuestion: z.string().min(1), + sectionContent: z.array(DynamicMarkdownSectionSchema), + replOutputs: z.record(z.string(), z.array(ReplCommandSchema)), + files: z.record(z.string(), z.string()), + execResults: z.record(z.string(), z.array(ReplOutputSchema)), +}); -type ChatParams = { - path: PagePath; - userQuestion: string; - sectionContent: DynamicMarkdownSection[]; - replOutputs: Record; - files: Record; - execResults: Record; -}; +type ChatParams = z.output; export type ChatStreamEvent = | { type: "chat"; chatId: string; sectionId: string } @@ -31,7 +39,14 @@ export async function POST(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const params = (await request.json()) as ChatParams; + const parseResult = ChatParamsSchema.safeParse(await request.json()); + if (!parseResult.success) { + return new Response( + parseResult.error.issues.map((e) => e.message).join(", "), + { status: 400 } + ); + } + const params: ChatParams = parseResult.data; const { path, userQuestion, sectionContent, replOutputs, files, execResults } = params; diff --git a/app/lib/docs.ts b/app/lib/docs.ts index 4104a9b0..224584f6 100644 --- a/app/lib/docs.ts +++ b/app/lib/docs.ts @@ -5,6 +5,7 @@ import yaml from "js-yaml"; import { isCloudflare } from "./detectCloudflare"; import { notFound } from "next/navigation"; import crypto from "node:crypto"; +import { z } from "zod"; /* Branded Types @@ -18,33 +19,56 @@ type Brand = K & { readonly __brand: T }; export type LangId = Brand; export type LangName = Brand; export type PageSlug = Brand; -export interface PagePath { - lang: LangId; - page: PageSlug; -} export type SectionId = Brand; -export interface MarkdownSection { +export const PagePathSchema = z.object({ + lang: z.string().transform((s) => s as LangId), + page: z.string().transform((s) => s as PageSlug), +}); +export type PagePath = z.output; + +export const MarkdownSectionSchema = z.object({ /** * セクションのmdファイル名 */ - file: string; + file: z.string(), /** * frontmatterに書くセクションid * (データベース上の sectionId) */ - id: SectionId; - level: number; - title: string; + id: z.string().transform((s) => s as SectionId), + level: z.number(), + title: z.string(), /** * frontmatterを除く、見出しも含めたもとのmarkdownの内容 */ - rawContent: string; + rawContent: z.string(), /** * rawContentのmd5ハッシュのbase64エンコード */ - md5: string; -} + md5: z.string(), +}); +export type MarkdownSection = z.output; + +export const ReplacedRangeSchema = z.object({ + start: z.number(), + end: z.number(), + id: z.string(), +}); +export type ReplacedRange = z.output; + +export const DynamicMarkdownSectionSchema = MarkdownSectionSchema.extend({ + /** + * ユーザーが今そのセクションを読んでいるかどうか + */ + inView: z.boolean(), + /** + * チャットの会話を元にAIが書き換えた後の内容 + */ + replacedContent: z.string(), + replacedRange: z.array(ReplacedRangeSchema), +}); +export type DynamicMarkdownSection = z.output; /** * 各言語のindex.ymlから読み込んだデータにid,index等を追加したデータ型 diff --git a/app/markdown/multiHighlight.tsx b/app/markdown/multiHighlight.tsx index aa5dd471..08690e42 100644 --- a/app/markdown/multiHighlight.tsx +++ b/app/markdown/multiHighlight.tsx @@ -6,12 +6,9 @@ import { ExtraProps } from "react-markdown"; import clsx from "clsx"; import { useChatId } from "@/(docs)/chatAreaState"; import Link from "next/link"; +import type { ReplacedRange } from "@/lib/docs"; -export interface ReplacedRange { - start: number; - end: number; - id: string; -} +export type { ReplacedRange }; export const remarkMultiHighlight: Plugin<[ReplacedRange[]], Root> = ( replacedRange?: ReplacedRange[] ) => { diff --git a/package-lock.json b/package-lock.json index e06b8298..ac4b7edb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22162,7 +22162,8 @@ "react": "^19", "react-dom": "^19", "swr": "^2", - "typescript": "^5" + "typescript": "^5", + "zod": "^4.0.17" }, "devDependencies": { "@testing-library/react": "^16.3.2", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 90edd5af..97bc1d5d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -20,7 +20,8 @@ "react": "^19", "react-dom": "^19", "swr": "^2", - "typescript": "^5" + "typescript": "^5", + "zod": "^4.0.17" }, "devDependencies": { "@testing-library/react": "^16.3.2", diff --git a/packages/runtime/src/interface.ts b/packages/runtime/src/interface.ts index 07fb44e6..0d8b26ff 100644 --- a/packages/runtime/src/interface.ts +++ b/packages/runtime/src/interface.ts @@ -1,4 +1,5 @@ import { MutexInterface } from "async-mutex"; +import { z } from "zod"; /** * 各言語の実行環境のインタフェース @@ -148,28 +149,33 @@ export interface RuntimeInfo { version?: string; } -export type ReplOutputType = - | "stdout" - | "stderr" - | "error" - | "return" - | "trace" - | "system"; -export interface ReplOutput { - type: ReplOutputType; // 出力の種類 - message: string; // 出力メッセージ -} -export interface UpdatedFile { - type: "file"; - filename: string; - content: string; -} +export const ReplOutputTypeSchema = z.enum([ + "stdout", + "stderr", + "error", + "return", + "trace", + "system", +]); +export type ReplOutputType = z.output; +export const ReplOutputSchema = z.object({ + type: ReplOutputTypeSchema, // 出力の種類 + message: z.string(), // 出力メッセージ +}); +export type ReplOutput = z.output; +export const UpdatedFileSchema = z.object({ + type: z.literal("file"), + filename: z.string(), + content: z.string(), +}); +export type UpdatedFile = z.output; -export interface ReplCommand { - command: string; - output: ReplOutput[]; - commandId?: string; // Optional for backward compatibility -} +export const ReplCommandSchema = z.object({ + command: z.string(), + output: z.array(ReplOutputSchema), + commandId: z.string().optional(), // Optional for backward compatibility +}); +export type ReplCommand = z.output; export type SyntaxStatus = "complete" | "incomplete" | "invalid"; // 構文チェックの結果 export const emptyMutex: MutexInterface = { From f759e87c91bf87500e169fbe11025f996f4088e8 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:11:59 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=E4=B8=8D=E8=A6=81=E3=81=AAsafeParse?= =?UTF-8?q?=E3=82=92parse=E3=81=AB=E3=80=81=E4=B8=8D=E8=A6=81=E3=81=AA?= =?UTF-8?q?=E5=86=8Dexport=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx | 3 +- .../@docs/[lang]/[pageId]/pageContent.tsx | 2 -- app/actions/deleteChat.ts | 9 ++---- app/actions/getRedirectFromChat.ts | 10 ++----- app/api/chat/route.ts | 29 ++++++++++--------- app/lib/docs.ts | 9 ++++-- app/markdown/markdown.tsx | 2 +- app/markdown/multiHighlight.tsx | 1 - app/sidebar.tsx | 3 +- 9 files changed, 30 insertions(+), 38 deletions(-) diff --git a/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx b/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx index 349f07bb..f38ce7ee 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/chatForm.tsx @@ -7,9 +7,8 @@ import { useState, FormEvent, useEffect } from "react"; // QuestionExampleParams, // } from "../actions/questionExample"; // import { getLanguageName } from "../pagesList"; -import { DynamicMarkdownSection } from "./pageContent"; import { useEmbedContext } from "@/terminal/embedContext"; -import { PagePath } from "@/lib/docs"; +import { DynamicMarkdownSection, PagePath } from "@/lib/docs"; import { useRouter } from "next/navigation"; import { ChatStreamEvent } from "@/api/chat/route"; import { useStreamingChatContext } from "@/(docs)/streamingChatContext"; diff --git a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx index 1d60f485..f2daec28 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx @@ -19,8 +19,6 @@ import Link from "next/link"; import { useChatId } from "@/(docs)/chatAreaState"; import { ChatWithMessages } from "@/lib/chatHistory"; -export type { DynamicMarkdownSection }; - interface PageContentProps { splitMdContent: MarkdownSection[]; langEntry: LanguageEntry; diff --git a/app/actions/deleteChat.ts b/app/actions/deleteChat.ts index 492a4a33..65ec9507 100644 --- a/app/actions/deleteChat.ts +++ b/app/actions/deleteChat.ts @@ -3,13 +3,8 @@ import { z } from "zod"; import { deleteChat, initContext } from "@/lib/chatHistory"; -const chatIdSchema = z.string().uuid(); - export async function deleteChatAction(chatId: string) { - const parsed = chatIdSchema.safeParse(chatId); - if (!parsed.success) { - throw new Error(parsed.error.issues.map((e) => e.message).join(", ")); - } + chatId = z.uuid().parse(chatId); const ctx = await initContext(); - await deleteChat(parsed.data, ctx); + await deleteChat(chatId, ctx); } diff --git a/app/actions/getRedirectFromChat.ts b/app/actions/getRedirectFromChat.ts index 669b1560..57e69c8d 100644 --- a/app/actions/getRedirectFromChat.ts +++ b/app/actions/getRedirectFromChat.ts @@ -6,20 +6,16 @@ import { LangId, PageSlug } from "@/lib/docs"; import { chat, section } from "@/schema/chat"; import { and, eq } from "drizzle-orm"; -const chatIdSchema = z.string().uuid(); - export async function getRedirectFromChat(chatId: string): Promise { - const parsed = chatIdSchema.safeParse(chatId); - if (!parsed.success) { - throw new Error(parsed.error.issues.map((e) => e.message).join(", ")); - } + chatId = z.uuid().parse(chatId); + const { drizzle, userId } = await initContext(); if (!userId) { throw new Error("Not authenticated"); } const chatData = (await drizzle.query.chat.findFirst({ - where: and(eq(chat.chatId, parsed.data), eq(chat.userId, userId)), + where: and(eq(chat.chatId, chatId), eq(chat.userId, userId)), with: { section: true, }, diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 064ca030..dd0beca0 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -13,7 +13,10 @@ import { PagePathSchema, SectionId, } from "@/lib/docs"; -import { ReplCommandSchema, ReplOutputSchema } from "@my-code/runtime/interface"; +import { + ReplCommandSchema, + ReplOutputSchema, +} from "@my-code/runtime/interface"; import { z } from "zod"; const ChatParamsSchema = z.object({ @@ -25,8 +28,6 @@ const ChatParamsSchema = z.object({ execResults: z.record(z.string(), z.array(ReplOutputSchema)), }); -type ChatParams = z.output; - export type ChatStreamEvent = | { type: "chat"; chatId: string; sectionId: string } | { type: "chunk"; text: string } @@ -41,14 +42,16 @@ export async function POST(request: NextRequest) { const parseResult = ChatParamsSchema.safeParse(await request.json()); if (!parseResult.success) { - return new Response( - parseResult.error.issues.map((e) => e.message).join(", "), - { status: 400 } - ); + return new Response(JSON.stringify(parseResult.error), { status: 400 }); } - const params: ChatParams = parseResult.data; - const { path, userQuestion, sectionContent, replOutputs, files, execResults } = - params; + const { + path, + userQuestion, + sectionContent, + replOutputs, + files, + execResults, + } = parseResult.data; const pagesList = await getPagesList(); const langName = pagesList.find((lang) => lang.id === path.lang)?.name; @@ -217,7 +220,7 @@ export async function POST(request: NextRequest) { prompt.join("\n") )) { console.log("Received chunk:", [chunk]); - + fullText += chunk; if (!headerParsed) { @@ -309,9 +312,7 @@ export async function POST(request: NextRequest) { await addMessagesAndDiffs( chatId, path, - [ - { role: "ai", content: cleanMessage }, - ], + [{ role: "ai", content: cleanMessage }], diffRaw, context ); diff --git a/app/lib/docs.ts b/app/lib/docs.ts index 224584f6..cbe8a367 100644 --- a/app/lib/docs.ts +++ b/app/lib/docs.ts @@ -25,7 +25,10 @@ export const PagePathSchema = z.object({ lang: z.string().transform((s) => s as LangId), page: z.string().transform((s) => s as PageSlug), }); -export type PagePath = z.output; +export interface PagePath { + lang: LangId; + page: PageSlug; +} export const MarkdownSectionSchema = z.object({ /** @@ -68,7 +71,9 @@ export const DynamicMarkdownSectionSchema = MarkdownSectionSchema.extend({ replacedContent: z.string(), replacedRange: z.array(ReplacedRangeSchema), }); -export type DynamicMarkdownSection = z.output; +export type DynamicMarkdownSection = z.output< + typeof DynamicMarkdownSectionSchema +>; /** * 各言語のindex.ymlから読み込んだデータにid,index等を追加したデータ型 diff --git a/app/markdown/markdown.tsx b/app/markdown/markdown.tsx index e55b51c5..2203c965 100644 --- a/app/markdown/markdown.tsx +++ b/app/markdown/markdown.tsx @@ -5,10 +5,10 @@ import remarkCjkFriendly from "remark-cjk-friendly"; import { MultiHighlightTag, remarkMultiHighlight, - ReplacedRange, } from "./multiHighlight"; import { Heading } from "./heading"; import { AutoCodeBlock } from "./codeBlock"; +import { ReplacedRange } from "@/lib/docs"; export function StyledMarkdown(props: { content: string; diff --git a/app/markdown/multiHighlight.tsx b/app/markdown/multiHighlight.tsx index 08690e42..b118c0eb 100644 --- a/app/markdown/multiHighlight.tsx +++ b/app/markdown/multiHighlight.tsx @@ -8,7 +8,6 @@ import { useChatId } from "@/(docs)/chatAreaState"; import Link from "next/link"; import type { ReplacedRange } from "@/lib/docs"; -export type { ReplacedRange }; export const remarkMultiHighlight: Plugin<[ReplacedRange[]], Root> = ( replacedRange?: ReplacedRange[] ) => { diff --git a/app/sidebar.tsx b/app/sidebar.tsx index 384074d1..eaf0034f 100644 --- a/app/sidebar.tsx +++ b/app/sidebar.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { LangId, LanguageEntry, PagePath, PageSlug } from "@/lib/docs"; +import { DynamicMarkdownSection, LangId, LanguageEntry, PagePath, PageSlug } from "@/lib/docs"; import { AccountMenu } from "./accountMenu"; import { ThemeToggle } from "./themeToggle"; import { @@ -15,7 +15,6 @@ import { import clsx from "clsx"; import { LanguageIcon } from "@/terminal/icons"; import { RuntimeLang } from "@my-code/runtime/languages"; -import { DynamicMarkdownSection } from "./(docs)/@docs/[lang]/[pageId]/pageContent"; export interface ISidebarMdContext { loadedPath: PagePath | null;