259 lines
8.8 KiB
JavaScript
259 lines
8.8 KiB
JavaScript
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}`;
|
|
}
|