import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, relative, resolve } from "node:path"; const allowedKeys = new Set([ "type", "title", "background", "problem_or_goal", "acceptance_criteria", "priority", "environment", "reproduction_steps", "actual_result", "expected_result", "expected_outcome", "impact_scope", "constraints", "non_goals", "desired_at", "references", "attachments", ]); export async function readStdin() { let input = ""; process.stdin.setEncoding("utf8"); for await (const chunk of process.stdin) input += chunk; if (!input.trim()) throw new Error("请通过 stdin 传入 JSON"); return JSON.parse(input); } /** Normalize the public V2 draft contract without asking for technical design. */ export function normalize(input) { if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("输入必须是 JSON 对象"); const unknown = Object.keys(input).filter((key) => !allowedKeys.has(key)); if (unknown.length) throw new Error(`存在禁止或未知字段: ${unknown.join(", ")}`); const background = input.problem_or_goal ?? input.background; const expectedResult = input.expected_outcome ?? input.expected_result; const value = { type: requiredEnum(input.type, "类型", ["需求", "Bug"]), title: requiredText(input.title, "标题"), background: requiredText(background, "背景与目标"), expected_result: requiredText(expectedResult, "期望结果"), priority: input.priority === undefined ? "P2" : requiredEnum(input.priority, "优先级", ["P0", "P1", "P2", "P3"]), acceptance_criteria: optionalList(input.acceptance_criteria, "验收提示"), impact_scope: optionalScopeList(input.impact_scope), constraints: optionalList(input.constraints, "约束"), non_goals: optionalList(input.non_goals, "非目标"), attachments: attachmentList(input.attachments), }; for (const [key, label] of [ ["environment", "发生环境"], ["actual_result", "实际结果"], ["references", "参考资料"], ]) { if (input[key] !== undefined) value[key] = requiredText(input[key], label); } if (input.desired_at !== undefined) value.desired_at = normalizeDesiredAt(input.desired_at); if (input.reproduction_steps !== undefined) value.reproduction_steps = optionalList(input.reproduction_steps, "复现步骤"); return value; } export function canonical(value) { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value && typeof value === "object") return `{${Object.keys(value) .sort() .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`) .join(",")}}`; return JSON.stringify(value); } export function sha256(value) { return createHash("sha256").update(value).digest("hex"); } export function createDraftIdentity(ownerOpenId) { const draftId = randomUUID(); const secret = randomBytes(32).toString("base64url"); return { draft_id: draftId, confirmation_token: `${draftId}.${secret}`, confirmation_token_hash: sha256(secret), submission_key: `intake-v2-${sha256(`${draftId}:${ownerOpenId}`).slice(0, 24)}`, }; } export function parseConfirmationToken(token) { if (typeof token !== "string") throw new Error("确认令牌缺失或已过期,请重新预览并确认"); const separator = token.indexOf("."); const draftId = token.slice(0, separator); const secret = token.slice(separator + 1); if ( separator < 1 || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( draftId, ) || !secret ) throw new Error("确认令牌缺失或已过期,请重新预览并确认"); return { draftId, secret }; } export function verifySecret(secret, expectedHash) { const actual = Buffer.from(sha256(secret), "hex"); const expected = Buffer.from(String(expectedHash ?? ""), "hex"); return actual.length === expected.length && timingSafeEqual(actual, expected); } export function contentDigest(value, ownerOpenId, attachments) { return sha256(canonical({ value, ownerOpenId, attachments })); } export function preview(value, attachmentManifest = []) { return { 类型: value.type, 标题: value.title, 背景与目标: value.background, 期望结果: value.expected_result, 优先级: value.priority, ...(value.acceptance_criteria.length ? { 可选验收提示: value.acceptance_criteria } : {}), ...(value.environment ? { 发生环境: value.environment } : {}), ...(value.reproduction_steps?.length ? { 复现步骤: value.reproduction_steps } : {}), ...(value.actual_result ? { 实际结果: value.actual_result } : {}), ...(value.impact_scope.length ? { 范围提示: value.impact_scope } : {}), ...(value.constraints.length ? { 约束: value.constraints } : {}), ...(value.non_goals.length ? { 非目标: value.non_goals } : {}), ...(value.desired_at ? { 期望完成时间: value.desired_at } : {}), ...(value.references ? { 参考资料: value.references } : {}), ...(attachmentManifest.length ? { 附件: attachmentManifest.map(({ path, size, sha256: digest }) => ({ path, size, sha256: digest, })), } : {}), }; } export async function attachmentManifest(paths, cwd = process.cwd()) { const root = await realpath(cwd); const manifest = []; for (const path of paths) { const target = await realpath(resolve(root, path)); const relativePath = relative(root, target); if (relativePath.startsWith("..") || isAbsolute(relativePath)) throw new Error(`附件超出当前目录: ${path}`); const metadata = await stat(target); if (!metadata.isFile()) throw new Error(`附件不是普通文件: ${path}`); manifest.push({ path, size: metadata.size, sha256: createHash("sha256").update(await readFile(target)).digest("hex"), }); } return manifest; } export async function assertSafeAttachmentPaths(paths, cwd = process.cwd()) { await attachmentManifest(paths, cwd); } export async function readJson(path, fallback) { try { return JSON.parse(await readFile(path, "utf8")); } catch (error) { if (error?.code === "ENOENT") return fallback; throw error; } } export async function writeJsonAtomic(path, value) { const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`; await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, }); await rename(temporary, path); } function requiredText(value, label) { if (typeof value !== "string" || !value.trim()) throw new Error(`${label}缺失`); return value.trim(); } function requiredList(value, label) { if (!Array.isArray(value) || !value.length) throw new Error(`${label}缺失`); return value.map((item) => requiredText(item, label)); } function optionalList(value, label) { return value === undefined || (Array.isArray(value) && value.length === 0) ? [] : requiredList(value, label); } function attachmentList(value) { const list = optionalList(value, "附件"); for (const path of list) if (isAbsolute(path) || path.split(/[\\/]/).includes("..")) throw new Error(`不安全的附件路径: ${path}`); return list; } function requiredEnum(value, label, options) { if (!options.includes(value)) throw new Error(`${label}必须是: ${options.join(" / ")}`); return value; } function optionalEnumList(value, label, options) { const list = optionalList(value, label); for (const item of list) requiredEnum(item, label, options); return [...new Set(list)]; } function optionalScopeList(value) { const list = optionalEnumList(value, "范围提示", [ "前端", "后端", "全栈", "部署", ]); if (list.includes("全栈") && (list.includes("前端") || list.includes("后端"))) throw new Error("范围提示选择全栈后不能再同时选择前端或后端"); return list; } function normalizeDesiredAt(value) { const text = requiredText(value, "期望完成时间"); const match = /^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2}):(\d{2}))?$/.exec(text); if (!match) throw new Error("期望完成时间必须是 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss"); const [, year, month, day, hour = "00", minute = "00", second = "00"] = match; const [y, m, d, h, min, s] = [year, month, day, hour, minute, second].map(Number); const validDate = m >= 1 && m <= 12 && d >= 1 && d <= new Date(Date.UTC(y, m, 0)).getUTCDate() && h <= 23 && min <= 59 && s <= 59; if (!validDate) throw new Error("期望完成时间不是有效日期时间"); return `${year}-${month}-${day} ${hour}:${minute}:${second}`; }