963 lines
44 KiB
TypeScript
963 lines
44 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import { mutateMdtodoDocument, readMdtodoTaskDetail } from "./mdtodo.ts";
|
|
import { createMdtodoSourceAdapter, normalizeProjectSourceInput, sourceProjection } from "./source-adapter.ts";
|
|
|
|
const contractVersion = "project-management-v1";
|
|
const serviceId = "hwlab-project-management";
|
|
|
|
export function createProjectManagementApp(options = {}) {
|
|
const env = options.env ?? process.env;
|
|
const store = options.store;
|
|
const logger = options.logger ?? console;
|
|
const sourceRoot = path.resolve(String(options.sourceRoot ?? env.HWLAB_PROJECT_MANAGEMENT_SOURCE_ROOT ?? process.cwd()));
|
|
const sourceId = safeToken(options.sourceId ?? env.HWLAB_PROJECT_MANAGEMENT_SOURCE_ID, "default-mdtodo");
|
|
const projectId = safeToken(options.projectId ?? env.HWLAB_PROJECT_MANAGEMENT_PROJECT_ID, "project_hwlab_v03");
|
|
const displayName = String(options.displayName ?? env.HWLAB_PROJECT_MANAGEMENT_SOURCE_NAME ?? "HWLAB MDTODO").trim() || "HWLAB MDTODO";
|
|
const maxFiles = positiveInteger(options.maxFiles ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_MAX_FILES, 300);
|
|
const taskMaxLimit = positiveInteger(options.taskMaxLimit ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_MAX_LIMIT, 500);
|
|
const taskDefaultLimit = Math.min(taskMaxLimit, positiveInteger(options.taskDefaultLimit ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_DEFAULT_LIMIT, 200));
|
|
const sourceAdapter = options.sourceAdapter ?? createMdtodoSourceAdapter({ env, hwpodNodeOpsHandler: options.hwpodNodeOpsHandler, fetchImpl: options.fetchImpl });
|
|
const defaultSource = normalizeProjectSourceInput({
|
|
sourceId,
|
|
sourceKind: "local-file-tree",
|
|
displayName,
|
|
projectId,
|
|
rootRef: sourceRoot,
|
|
mdtodoRootRef: ".",
|
|
maxFiles
|
|
}, { projectId, displayName, rootRef: sourceRoot, maxFiles }).source;
|
|
let initialized = false;
|
|
let lastProjection = null;
|
|
let lastError = null;
|
|
let initializing = null;
|
|
|
|
async function initialize() {
|
|
if (initialized) return lastProjection;
|
|
if (initializing) return initializing;
|
|
initializing = (async () => {
|
|
const startedAt = new Date().toISOString();
|
|
await store.ensureSchema();
|
|
const bootstrapSources = await projectManagementBootstrapSources({ env, projectId, displayName, sourceRoot, maxFiles });
|
|
const sources = dedupeProjectSources([defaultSource, ...bootstrapSources]);
|
|
const projections = [];
|
|
for (const source of sources) {
|
|
await store.upsertSource(source);
|
|
try {
|
|
const documents = await sourceAdapter.discover(source);
|
|
await store.replaceProjection(source, documents);
|
|
projections.push({ ...sourceProjection(source, documents), status: "ready" });
|
|
} catch (error) {
|
|
if (source.sourceId === defaultSource.sourceId) throw error;
|
|
const blocker = { code: error?.code ?? "project_source_projection_failed", message: error?.message ?? String(error) };
|
|
projections.push({ ...sourceProjection(source, []), status: "blocked", blocker });
|
|
logger.warn?.(JSON.stringify({ event: "project-management-bootstrap-source-projection-failed", serviceId, sourceId: source.sourceId, sourceKind: source.sourceKind, ...blocker }));
|
|
}
|
|
}
|
|
lastProjection = summarizeProjectManagementProjection(projections, startedAt);
|
|
initialized = true;
|
|
lastError = null;
|
|
logger.info?.(JSON.stringify({ event: "project-management-projection-ready", serviceId, ...lastProjection }));
|
|
return lastProjection;
|
|
})();
|
|
try {
|
|
return await initializing;
|
|
} catch (error) {
|
|
lastError = error;
|
|
initialized = false;
|
|
throw error;
|
|
} finally {
|
|
initializing = null;
|
|
}
|
|
}
|
|
|
|
async function fetchHandler(request) {
|
|
const url = new URL(request.url);
|
|
try {
|
|
if (url.pathname === "/health/live" || url.pathname === "/live") {
|
|
return json(200, healthPayload("live", { initialized, lastProjection, lastError }));
|
|
}
|
|
if (url.pathname === "/health/ready" || url.pathname === "/health") {
|
|
try {
|
|
const projection = await initialize();
|
|
return json(200, healthPayload("ready", { initialized: true, lastProjection: projection, lastError: null }));
|
|
} catch (error) {
|
|
return json(503, healthPayload("ready", { initialized: false, lastProjection, lastError: error }));
|
|
}
|
|
}
|
|
|
|
if (!url.pathname.startsWith("/v1/project-management")) return jsonError(404, "not_found", "Project management route is not implemented");
|
|
if (url.pathname === "/v1/project-management/workbench-links" && request.method === "POST") {
|
|
await initialize();
|
|
return await handleCreateWorkbenchLink(request, store);
|
|
}
|
|
if (url.pathname === "/v1/project-management/mdtodo/sources" && request.method === "POST") {
|
|
await initialize();
|
|
return await handleUpsertMdtodoSource(request, store, { mode: "create" });
|
|
}
|
|
const sourceAction = matchSourceAction(url.pathname);
|
|
if (sourceAction && request.method === "PATCH" && !sourceAction.action) {
|
|
await initialize();
|
|
return await handleUpsertMdtodoSource(request, store, { mode: "update", sourceId: sourceAction.sourceId });
|
|
}
|
|
if (sourceAction?.action === "probe" && request.method === "POST") {
|
|
await initialize();
|
|
return await handleProbeMdtodoSource(store, sourceAdapter, sourceAction.sourceId, actorFromRequest(request));
|
|
}
|
|
if (sourceAction?.action === "reindex" && request.method === "POST") {
|
|
await initialize();
|
|
return await handleReindexMdtodoSource(store, sourceAdapter, sourceAction.sourceId, actorFromRequest(request));
|
|
}
|
|
const fileContent = matchFileContent(url.pathname);
|
|
if (fileContent && request.method === "PATCH") {
|
|
await initialize();
|
|
return await handleWriteMdtodoFile(request, url, store, sourceAdapter, fileContent.fileRef, actorFromRequest(request));
|
|
}
|
|
const taskAction = matchTaskAction(url.pathname);
|
|
if (url.pathname === "/v1/project-management/mdtodo/tasks" && request.method === "POST") {
|
|
await initialize();
|
|
return await handleCreateMdtodoTask(request, store, sourceAdapter, actorFromRequest(request));
|
|
}
|
|
if (taskAction && request.method === "PATCH") {
|
|
await initialize();
|
|
return await handlePatchMdtodoTask(request, store, sourceAdapter, taskAction.taskRef, actorFromRequest(request));
|
|
}
|
|
if (taskAction && request.method === "DELETE") {
|
|
await initialize();
|
|
return await handleDeleteMdtodoTask(request, store, sourceAdapter, taskAction.taskRef, actorFromRequest(request));
|
|
}
|
|
if (request.method !== "GET" && request.method !== "HEAD") return jsonError(405, "method_not_allowed", "Project management API only accepts read requests plus Workbench link writes");
|
|
|
|
await initialize();
|
|
if (url.pathname === "/v1/project-management" || url.pathname === "/v1/project-management/navigation") {
|
|
return json(200, envelope({ navigation: navigationPayload(actorFromRequest(request)), projection: lastProjection }));
|
|
}
|
|
if (url.pathname === "/v1/project-management/projects") {
|
|
const sources = await store.listSources();
|
|
return json(200, envelope({ projects: projectsFromSources(sources) }));
|
|
}
|
|
if (url.pathname === "/v1/project-management/mdtodo/sources") {
|
|
return json(200, envelope({ sources: await store.listSources() }));
|
|
}
|
|
if (url.pathname === "/v1/project-management/mdtodo/files") {
|
|
return json(200, envelope({ files: await store.listDocuments({ sourceId: queryToken(url, "sourceId") }) }));
|
|
}
|
|
if (fileContent) {
|
|
return await handleReadMdtodoFile(url, store, sourceAdapter, fileContent.fileRef);
|
|
}
|
|
if (url.pathname === "/v1/project-management/mdtodo/launch-context") {
|
|
return await handleReadMdtodoLaunchContext(url, store);
|
|
}
|
|
if (url.pathname === "/v1/project-management/mdtodo/task-detail") {
|
|
return await handleReadMdtodoTaskDetail(url, store, sourceAdapter);
|
|
}
|
|
if (url.pathname === "/v1/project-management/mdtodo/report-preview") {
|
|
return await handleReadMdtodoReportPreview(url, store, sourceAdapter);
|
|
}
|
|
if (url.pathname === "/v1/project-management/mdtodo/tasks") {
|
|
const taskFilters = {
|
|
taskRef: queryOpaque(url, "taskRef"),
|
|
sourceId: queryToken(url, "sourceId"),
|
|
fileRef: queryToken(url, "fileRef"),
|
|
projectId: queryToken(url, "projectId"),
|
|
parentTaskRef: queryOpaque(url, "parentTaskRef"),
|
|
status: queryToken(url, "status"),
|
|
search: querySearch(url, "search")
|
|
};
|
|
const page = taskListPage(url, { defaultLimit: taskDefaultLimit, maxLimit: taskMaxLimit });
|
|
const [total, tasks] = await Promise.all([
|
|
store.countTasks(taskFilters),
|
|
store.listTasks({ ...taskFilters, limit: page.limit, offset: page.offset })
|
|
]);
|
|
return json(200, envelope({
|
|
tasks,
|
|
page: { ...page, total, hasMore: page.offset + tasks.length < total }
|
|
}));
|
|
}
|
|
if (url.pathname === "/v1/project-management/workbench-links") {
|
|
return json(200, envelope({
|
|
links: await store.listWorkbenchLinks({
|
|
taskRef: queryOpaque(url, "taskRef"),
|
|
projectId: queryToken(url, "projectId"),
|
|
sessionId: queryOpaque(url, "sessionId")
|
|
})
|
|
}));
|
|
}
|
|
return jsonError(404, "not_found", "Project management route is not implemented");
|
|
} catch (error) {
|
|
logger.error?.(JSON.stringify({ event: "project-management-request-failed", serviceId, path: url.pathname, code: error?.code ?? "project_management_error", message: error?.message ?? String(error) }));
|
|
return jsonError(error?.statusCode ?? 500, error?.code ?? "project_management_error", error?.message ?? "Project management request failed");
|
|
}
|
|
}
|
|
|
|
return {
|
|
initialize,
|
|
fetch: fetchHandler,
|
|
async close() {
|
|
if (typeof store?.close === "function") await store.close();
|
|
}
|
|
};
|
|
}
|
|
|
|
async function projectManagementBootstrapSources({ env, projectId, displayName, sourceRoot, maxFiles }) {
|
|
const inputs = await readProjectManagementBootstrapSourceInputs(env);
|
|
return inputs.map((input, index) => {
|
|
const normalized = normalizeProjectSourceInput(input, { projectId, displayName, rootRef: sourceRoot, maxFiles });
|
|
if (!normalized.ok) {
|
|
const error = Object.assign(new Error(`bootstrap source[${index}] invalid: ${normalized.message}`), {
|
|
code: normalized.code,
|
|
statusCode: normalized.status
|
|
});
|
|
throw error;
|
|
}
|
|
return normalized.source;
|
|
});
|
|
}
|
|
|
|
async function readProjectManagementBootstrapSourceInputs(env) {
|
|
const pathValue = String(env.HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH ?? "").trim();
|
|
const inlineValue = String(env.HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_JSON ?? "").trim();
|
|
if (!pathValue && !inlineValue) return [];
|
|
const text = pathValue ? await readFile(pathValue, "utf8") : inlineValue;
|
|
const parsed = JSON.parse(text);
|
|
if (Array.isArray(parsed)) return parsed;
|
|
if (Array.isArray(parsed?.sources)) return parsed.sources;
|
|
const error = Object.assign(new Error("Project management bootstrap sources must be a JSON array or an object with a sources array"), {
|
|
code: "invalid_project_management_bootstrap_sources",
|
|
statusCode: 400
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
function dedupeProjectSources(sources) {
|
|
const byId = new Map();
|
|
for (const source of sources) byId.set(source.sourceId, source);
|
|
return [...byId.values()];
|
|
}
|
|
|
|
function summarizeProjectManagementProjection(projections, startedAt) {
|
|
if (projections.length === 1) return { ...projections[0], startedAt };
|
|
return {
|
|
sourceId: projections[0]?.sourceId ?? "project-management",
|
|
sourceCount: projections.length,
|
|
sources: projections,
|
|
documentCount: projections.reduce((sum, item) => sum + Number(item.documentCount ?? 0), 0),
|
|
taskCount: projections.reduce((sum, item) => sum + Number(item.taskCount ?? 0), 0),
|
|
projectedAt: new Date().toISOString(),
|
|
startedAt,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function healthPayload(mode, state) {
|
|
const blockers = state.lastError ? [{ code: state.lastError.code ?? "project_management_not_ready", message: state.lastError.message ?? String(state.lastError) }] : [];
|
|
return {
|
|
contractVersion,
|
|
serviceId,
|
|
status: blockers.length ? "blocked" : "ok",
|
|
ready: mode === "live" ? true : blockers.length === 0 && state.initialized === true,
|
|
mode,
|
|
projection: state.lastProjection,
|
|
blockers,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function envelope(payload) {
|
|
return {
|
|
contractVersion,
|
|
serviceId,
|
|
ok: true,
|
|
...payload,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function navigationPayload(actor) {
|
|
return {
|
|
id: "project-management",
|
|
label: "Project Management",
|
|
actor,
|
|
items: [
|
|
{ id: "projects", label: "Projects", apiRoute: "/v1/project-management/projects" },
|
|
{ id: "mdtodo", label: "MDTODO", apiRoute: "/v1/project-management/mdtodo/tasks" }
|
|
],
|
|
capabilities: {
|
|
mdtodoProjection: true,
|
|
workbenchLaunch: true,
|
|
workbenchLinks: true,
|
|
sourceOfTruth: "markdown-files"
|
|
}
|
|
};
|
|
}
|
|
|
|
function projectsFromSources(sources) {
|
|
const projects = new Map();
|
|
for (const source of sources) {
|
|
const projectId = source.projectId || "project_hwlab_v03";
|
|
if (!projects.has(projectId)) {
|
|
projects.set(projectId, {
|
|
projectId,
|
|
name: source.displayName || projectId,
|
|
sourceIds: [],
|
|
sourceOfTruth: "markdown-files",
|
|
status: source.status || "active"
|
|
});
|
|
}
|
|
projects.get(projectId).sourceIds.push(source.sourceId);
|
|
}
|
|
return [...projects.values()];
|
|
}
|
|
|
|
function actorFromRequest(request) {
|
|
const actorId = request.headers.get("x-hwlab-actor-id") || null;
|
|
if (!actorId) return null;
|
|
return {
|
|
id: actorId,
|
|
role: request.headers.get("x-hwlab-actor-role") || null,
|
|
authMethod: request.headers.get("x-hwlab-auth-method") || null
|
|
};
|
|
}
|
|
|
|
async function readJsonObject(request, maxBytes = 32768) {
|
|
const text = await request.text();
|
|
if (text.length > maxBytes) return { ok: false, code: "body_too_large", message: "Project management request body is too large" };
|
|
try {
|
|
const value = text ? JSON.parse(text) : {};
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, code: "invalid_params", message: "body must be a JSON object" };
|
|
return { ok: true, value };
|
|
} catch (error) {
|
|
return { ok: false, code: "parse_error", message: "Invalid JSON body", reason: error?.message ?? String(error) };
|
|
}
|
|
}
|
|
|
|
async function handleCreateWorkbenchLink(request, store) {
|
|
const body = await readJsonObject(request);
|
|
if (!body.ok) return jsonError(400, body.code, body.message);
|
|
const normalized = normalizeWorkbenchLinkInput(body.value, actorFromRequest(request));
|
|
if (!normalized.ok) return jsonError(400, normalized.code, normalized.message);
|
|
const link = await store.upsertWorkbenchLink(normalized.link);
|
|
return json(201, envelope({ link }));
|
|
}
|
|
|
|
async function handleUpsertMdtodoSource(request, store, options = {}) {
|
|
const body = await readJsonObject(request);
|
|
if (!body.ok) return jsonError(400, body.code, body.message);
|
|
const existing = options.mode === "update" ? await getSource(store, options.sourceId) : null;
|
|
if (options.mode === "update" && !existing) return jsonError(404, "source_not_found", "MDTODO source was not found");
|
|
const normalized = normalizeProjectSourceInput({ ...(existing ?? {}), ...body.value, sourceId: options.sourceId ?? body.value.sourceId }, existing ?? {});
|
|
if (!normalized.ok) return jsonError(normalized.status ?? 400, normalized.code, normalized.message);
|
|
await store.upsertSource(normalized.source);
|
|
await recordAuditEvent(store, request, {
|
|
action: options.mode === "update" ? "mdtodo.source.update" : "mdtodo.source.create",
|
|
targetType: "mdtodo_source",
|
|
targetId: normalized.source.sourceId,
|
|
outcome: "accepted",
|
|
sourceKind: normalized.source.sourceKind
|
|
});
|
|
return json(options.mode === "update" ? 200 : 201, envelope({ source: normalized.source }));
|
|
}
|
|
|
|
async function handleProbeMdtodoSource(store, sourceAdapter, sourceId, actor) {
|
|
const source = await getSource(store, sourceId);
|
|
if (!source) return jsonError(404, "source_not_found", "MDTODO source was not found");
|
|
const probe = await sourceAdapter.probe(source);
|
|
await recordAuditEvent(store, { actor }, { action: "mdtodo.source.probe", targetType: "mdtodo_source", targetId: source.sourceId, outcome: "succeeded", sourceKind: source.sourceKind });
|
|
return json(200, envelope({ probe }));
|
|
}
|
|
|
|
async function handleReindexMdtodoSource(store, sourceAdapter, sourceId, actor) {
|
|
const source = await getSource(store, sourceId);
|
|
if (!source) return jsonError(404, "source_not_found", "MDTODO source was not found");
|
|
const documents = await sourceAdapter.discover(source);
|
|
await store.replaceProjection(source, documents);
|
|
const projection = sourceProjection(source, documents);
|
|
await recordAuditEvent(store, { actor }, { action: "mdtodo.source.reindex", targetType: "mdtodo_source", targetId: source.sourceId, outcome: "succeeded", documentCount: projection.documentCount, taskCount: projection.taskCount });
|
|
return json(200, envelope({ projection }));
|
|
}
|
|
|
|
async function handleReadMdtodoFile(url, store, sourceAdapter, fileRef) {
|
|
const sourceId = queryToken(url, "sourceId");
|
|
if (!sourceId) return jsonError(400, "source_id_required", "sourceId is required");
|
|
const { source, document, error } = await resolveMdtodoDocument(store, sourceId, fileRef);
|
|
if (error) return error;
|
|
const file = await sourceAdapter.readFile(source, document.relativePath);
|
|
return json(200, envelope({ file }));
|
|
}
|
|
|
|
async function handleReadMdtodoTaskDetail(url, store, sourceAdapter) {
|
|
const taskRef = queryOpaque(url, "taskRef");
|
|
if (!taskRef) return jsonError(400, "task_ref_required", "taskRef is required");
|
|
const detail = await loadTaskDetail({ store, sourceAdapter, taskRef, allowProjectedFallback: true });
|
|
if (detail.error) return detail.error;
|
|
if (detail.availability?.state === "unavailable") {
|
|
return json(200, envelope({
|
|
status: "partial",
|
|
task: detail.task,
|
|
detail: null,
|
|
file: fileSummary(detail.document),
|
|
detailAuthority: detail.availability
|
|
}));
|
|
}
|
|
return json(200, envelope({
|
|
status: "ready",
|
|
task: { ...detail.task, bodyPreview: detail.detail.bodyPreview, linkCount: detail.detail.links.length },
|
|
detail: detail.detail,
|
|
file: fileSummary(detail.file),
|
|
detailAuthority: taskDetailAuthority("ready", detail.source)
|
|
}));
|
|
}
|
|
|
|
async function handleReadMdtodoLaunchContext(url, store) {
|
|
const taskRef = queryOpaque(url, "taskRef");
|
|
if (!taskRef) return jsonError(400, "task_ref_required", "taskRef is required");
|
|
const task = await getTaskByRef(store, taskRef);
|
|
if (!task) return jsonError(404, "task_not_found", "MDTODO task was not found");
|
|
const source = await getSource(store, task.sourceId);
|
|
if (!source) return jsonError(404, "source_not_found", "MDTODO source was not found");
|
|
const executionContext = buildMdtodoExecutionContext({ task, source });
|
|
if (!executionContext.ok) return jsonError(executionContext.status, executionContext.code, executionContext.message);
|
|
const launchContext = buildMdtodoLaunchContext({ task, source, executionContext: executionContext.context });
|
|
return json(200, envelope({ task, source: sourceSummary(source), executionContext: executionContext.context, launchContext }));
|
|
}
|
|
|
|
async function handleReadMdtodoReportPreview(url, store, sourceAdapter) {
|
|
const taskRef = queryOpaque(url, "taskRef");
|
|
const linkId = queryToken(url, "linkId");
|
|
if (!taskRef) return jsonError(400, "task_ref_required", "taskRef is required");
|
|
if (!linkId) return jsonError(400, "link_id_required", "linkId is required");
|
|
let detail;
|
|
try {
|
|
detail = await loadTaskDetail({ store, sourceAdapter, taskRef });
|
|
} catch (error) {
|
|
if (isHwpodAvailabilityError(error)) return hwpodAvailabilityError(error);
|
|
throw error;
|
|
}
|
|
if (detail.error) return detail.error;
|
|
const link = detail.detail.links.find((item) => item.linkId === linkId);
|
|
if (!link) return jsonError(404, "report_link_not_found", "MDTODO report link was not found");
|
|
if (link.kind !== "markdown-report" || !link.relativePath) return jsonError(400, "unsupported_report_link", "Only safe relative Markdown report links can be previewed");
|
|
let report;
|
|
try {
|
|
report = await sourceAdapter.readLinkedMarkdown(detail.source, detail.document.relativePath, link.href);
|
|
} catch (error) {
|
|
if (isHwpodAvailabilityError(error)) return hwpodAvailabilityError(error);
|
|
throw error;
|
|
}
|
|
return json(200, envelope({
|
|
task: detail.task,
|
|
link,
|
|
report: {
|
|
...report,
|
|
linkId: link.linkId,
|
|
label: link.label,
|
|
href: link.href,
|
|
valuesRedacted: false
|
|
},
|
|
file: fileSummary(detail.file)
|
|
}));
|
|
}
|
|
|
|
async function loadTaskDetail({ store, sourceAdapter, taskRef, allowProjectedFallback = false }) {
|
|
const task = await getTaskByRef(store, taskRef);
|
|
if (!task) return { error: jsonError(404, "task_not_found", "MDTODO task was not found") };
|
|
const { source, document, error } = await resolveMdtodoDocument(store, task.sourceId, task.fileRef);
|
|
if (error) return { error };
|
|
let file;
|
|
try {
|
|
file = await sourceAdapter.readFile(source, document.relativePath);
|
|
} catch (readError) {
|
|
if (allowProjectedFallback && source.sourceKind === "hwpod-workspace" && isHwpodAvailabilityError(readError)) {
|
|
return {
|
|
source,
|
|
document,
|
|
file: null,
|
|
task,
|
|
detail: null,
|
|
availability: taskDetailAuthority("unavailable", source, readError)
|
|
};
|
|
}
|
|
throw readError;
|
|
}
|
|
const detail = readMdtodoTaskDetail(file.content, {
|
|
sourceId: source.sourceId,
|
|
fileRef: document.fileRef,
|
|
relativePath: document.relativePath,
|
|
projectId: document.projectId,
|
|
taskRef: task.taskRef,
|
|
rxxId: task.taskId
|
|
});
|
|
if (!detail) return { error: jsonError(404, "task_detail_not_found", "MDTODO task detail was not found in source Markdown") };
|
|
return { source, document, file, task, detail };
|
|
}
|
|
|
|
function isHwpodAvailabilityError(error) {
|
|
const statusCode = Number(error?.statusCode);
|
|
return error?.sourceKind === "hwpod-workspace" && [502, 503, 504].includes(statusCode);
|
|
}
|
|
|
|
function hwpodAvailabilityError(error) {
|
|
return jsonError(
|
|
Number(error?.statusCode),
|
|
safeToken(error?.code, "hwpod_source_unavailable"),
|
|
"HWPOD 源当前不可读;报告预览需等待 Node 恢复。"
|
|
);
|
|
}
|
|
|
|
function taskDetailAuthority(state, source, error = null) {
|
|
const unavailable = state === "unavailable";
|
|
return {
|
|
state,
|
|
authority: unavailable ? "project-management-projection" : "source-markdown",
|
|
sourceKind: source?.sourceKind ?? null,
|
|
code: unavailable ? safeToken(error?.code, "hwpod_source_unavailable") : null,
|
|
message: unavailable
|
|
? "HWPOD 源当前不可读;页面继续展示最近一次项目投影,正文编辑和报告预览需等待 Node 恢复。"
|
|
: null,
|
|
retryable: unavailable,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
async function handleWriteMdtodoFile(request, url, store, sourceAdapter, fileRef, actor) {
|
|
const body = await readJsonObject(request, 1024 * 1024 + 4096);
|
|
if (!body.ok) return jsonError(body.code === "body_too_large" ? 413 : 400, body.code, body.message);
|
|
const sourceId = safeToken(body.value.sourceId, queryToken(url, "sourceId"));
|
|
if (!sourceId) return jsonError(400, "source_id_required", "sourceId is required");
|
|
if (typeof body.value.content !== "string") return jsonError(400, "content_required", "content must be a string");
|
|
const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision);
|
|
if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO writes");
|
|
const { source, document, error } = await resolveMdtodoDocument(store, sourceId, fileRef);
|
|
if (error) return error;
|
|
const file = await sourceAdapter.writeFile(source, document.relativePath, body.value.content, expectedFingerprint);
|
|
const documents = await sourceAdapter.discover(source);
|
|
await store.replaceProjection(source, documents);
|
|
const projection = sourceProjection(source, documents);
|
|
await recordAuditEvent(store, { actor }, { action: "mdtodo.file.write", targetType: "mdtodo_file", targetId: `${source.sourceId}:${fileRef}`, outcome: "succeeded", sourceKind: source.sourceKind, fileRef, revision: file.revision });
|
|
return json(200, envelope({ file, projection }));
|
|
}
|
|
|
|
async function handlePatchMdtodoTask(request, store, sourceAdapter, taskRef, actor) {
|
|
const body = await readJsonObject(request, 256 * 1024);
|
|
if (!body.ok) return jsonError(body.code === "body_too_large" ? 413 : 400, body.code, body.message);
|
|
const task = await getTaskByRef(store, taskRef);
|
|
if (!task) return jsonError(404, "task_not_found", "MDTODO task was not found");
|
|
const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision ?? body.value.expectedRevision);
|
|
if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO task writes");
|
|
const operations = [];
|
|
if (typeof body.value.title === "string") operations.push({ type: "updateTitle", rxxId: task.taskId, title: body.value.title });
|
|
if (typeof body.value.status === "string") operations.push({ type: "updateStatus", rxxId: task.taskId, status: body.value.status });
|
|
if (typeof body.value.body === "string") operations.push({ type: "updateBody", rxxId: task.taskId, body: body.value.body });
|
|
if (operations.length === 0) return jsonError(400, "mutation_required", "At least one MDTODO task field is required");
|
|
return mutateTaskDocument({ store, sourceAdapter, sourceId: task.sourceId, fileRef: task.fileRef, expectedFingerprint, operations, auditAction: "mdtodo.task.update", targetTaskRef: taskRef, actor, status: 200 });
|
|
}
|
|
|
|
async function handleCreateMdtodoTask(request, store, sourceAdapter, actor) {
|
|
const body = await readJsonObject(request, 256 * 1024);
|
|
if (!body.ok) return jsonError(body.code === "body_too_large" ? 413 : 400, body.code, body.message);
|
|
const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision ?? body.value.expectedRevision);
|
|
if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO task writes");
|
|
const title = shortText(body.value.title, 500);
|
|
if (!title) return jsonError(400, "title_required", "title is required for MDTODO task creation");
|
|
const parentTask = body.value.parentTaskRef ? await getTaskByRef(store, safeOpaqueValue(body.value.parentTaskRef)) : null;
|
|
const afterTask = body.value.afterTaskRef ? await getTaskByRef(store, safeOpaqueValue(body.value.afterTaskRef)) : null;
|
|
if (body.value.parentTaskRef && !parentTask) return jsonError(404, "parent_task_not_found", "Parent MDTODO task was not found");
|
|
if (body.value.afterTaskRef && !afterTask) return jsonError(404, "after_task_not_found", "Continuation MDTODO task was not found");
|
|
const sourceId = parentTask?.sourceId ?? afterTask?.sourceId ?? safeToken(body.value.sourceId, null);
|
|
const fileRef = parentTask?.fileRef ?? afterTask?.fileRef ?? safeToken(body.value.fileRef, null);
|
|
if (!sourceId || !fileRef) return jsonError(400, "task_target_required", "sourceId and fileRef are required for root MDTODO task creation");
|
|
const operation = {
|
|
type: "addTask",
|
|
title,
|
|
status: body.value.status ?? "open",
|
|
body: typeof body.value.body === "string" ? body.value.body : "",
|
|
parentRxxId: parentTask?.taskId ?? null,
|
|
afterRxxId: afterTask?.taskId ?? null
|
|
};
|
|
return mutateTaskDocument({ store, sourceAdapter, sourceId, fileRef, expectedFingerprint, operations: [operation], auditAction: "mdtodo.task.create", targetTaskRef: parentTask?.taskRef ?? afterTask?.taskRef ?? `${sourceId}:${fileRef}`, actor, status: 201 });
|
|
}
|
|
|
|
async function handleDeleteMdtodoTask(request, store, sourceAdapter, taskRef, actor) {
|
|
const body = await readJsonObject(request, 32768);
|
|
if (!body.ok) return jsonError(400, body.code, body.message);
|
|
const task = await getTaskByRef(store, taskRef);
|
|
if (!task) return jsonError(404, "task_not_found", "MDTODO task was not found");
|
|
const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision ?? body.value.expectedRevision);
|
|
if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO task writes");
|
|
return mutateTaskDocument({ store, sourceAdapter, sourceId: task.sourceId, fileRef: task.fileRef, expectedFingerprint, operations: [{ type: "deleteTask", rxxId: task.taskId }], auditAction: "mdtodo.task.delete", targetTaskRef: taskRef, actor, status: 200 });
|
|
}
|
|
|
|
async function mutateTaskDocument({ store, sourceAdapter, sourceId, fileRef, expectedFingerprint, operations, auditAction, targetTaskRef, actor, status }) {
|
|
const { source, document, error } = await resolveMdtodoDocument(store, sourceId, fileRef);
|
|
if (error) return error;
|
|
const current = await sourceAdapter.readFile(source, document.relativePath);
|
|
let markdown = current.content;
|
|
let mutation = null;
|
|
for (const operation of operations) {
|
|
mutation = mutateMdtodoDocument(markdown, { ...operation, expectedFingerprint: mutation ? null : expectedFingerprint }, { sourceId: source.sourceId, relativePath: document.relativePath, projectId: document.projectId });
|
|
if (!mutation.ok) return jsonError(mutation.conflict ? 409 : 400, mutation.code ?? "mdtodo_mutation_failed", mutation.message ?? "MDTODO task mutation failed");
|
|
markdown = mutation.markdown;
|
|
}
|
|
const file = await sourceAdapter.writeFile(source, document.relativePath, markdown, expectedFingerprint);
|
|
const documents = await sourceAdapter.discover(source);
|
|
await store.replaceProjection(source, documents);
|
|
const projection = sourceProjection(source, documents);
|
|
const tasks = await store.listTasks({ sourceId: source.sourceId, fileRef });
|
|
const changedTaskRef = mutation?.changedTaskId ? `mdtodo:${source.sourceId}:${fileRef}:${mutation.changedTaskId}` : null;
|
|
const task = changedTaskRef ? tasks.find((item) => item.taskRef === changedTaskRef) ?? null : null;
|
|
await recordAuditEvent(store, { actor }, { action: auditAction, targetType: "mdtodo_task", targetId: targetTaskRef, outcome: "succeeded", sourceKind: source.sourceKind, fileRef, revision: file.revision, changedTaskId: mutation?.changedTaskId ?? null, affectedTaskIds: mutation?.affectedTaskIds ?? [] });
|
|
return json(status, envelope({ task, affectedTaskIds: mutation?.affectedTaskIds ?? [], changedTaskId: mutation?.changedTaskId ?? null, file: fileSummary(file), projection }));
|
|
}
|
|
|
|
async function getTaskByRef(store, taskRef) {
|
|
const safeTaskRef = safeOpaqueValue(taskRef);
|
|
if (!safeTaskRef) return null;
|
|
if (typeof store.getTask === "function") return store.getTask(safeTaskRef);
|
|
return (await store.listTasks({ taskRef: safeTaskRef, limit: 1 }))[0] ?? null;
|
|
}
|
|
|
|
function fileSummary(file) {
|
|
return {
|
|
sourceId: file.sourceId,
|
|
fileRef: file.fileRef,
|
|
relativePath: file.relativePath,
|
|
fingerprint: file.fingerprint,
|
|
revision: file.revision,
|
|
parserMode: file.parserMode,
|
|
taskCount: file.taskCount,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
async function resolveMdtodoDocument(store, sourceId, fileRef) {
|
|
const source = await getSource(store, sourceId);
|
|
if (!source) return { error: jsonError(404, "source_not_found", "MDTODO source was not found") };
|
|
const documents = await store.listDocuments({ sourceId });
|
|
const document = documents.find((item) => item.fileRef === fileRef);
|
|
if (!document) return { error: jsonError(404, "file_not_found", "MDTODO file was not found") };
|
|
return { source, document };
|
|
}
|
|
|
|
async function getSource(store, sourceId) {
|
|
if (!sourceId) return null;
|
|
if (typeof store.getSource === "function") return store.getSource(sourceId);
|
|
return (await store.listSources()).find((source) => source.sourceId === sourceId) ?? null;
|
|
}
|
|
|
|
function normalizeWorkbenchLinkInput(input, actor) {
|
|
const projectId = safeToken(input.projectId, null);
|
|
const taskRef = safeOpaqueValue(input.taskRef);
|
|
const sessionId = safeOpaqueValue(input.sessionId);
|
|
if (!projectId) return { ok: false, code: "project_id_required", message: "ProjectWorkbenchLink requires projectId" };
|
|
if (!taskRef) return { ok: false, code: "task_ref_required", message: "ProjectWorkbenchLink requires taskRef" };
|
|
if (!sessionId) return { ok: false, code: "session_id_required", message: "ProjectWorkbenchLink requires sessionId" };
|
|
const traceId = safeOpaqueValue(input.traceId);
|
|
const role = safeToken(input.role, "launch");
|
|
const link = sanitizeLinkJson(input.link);
|
|
return {
|
|
ok: true,
|
|
link: {
|
|
linkId: safeToken(input.linkId, stableLinkId({ projectId, taskRef, sessionId })),
|
|
projectId,
|
|
taskRef,
|
|
sessionId,
|
|
traceId,
|
|
createdBy: safeOpaqueValue(input.createdBy) ?? actor?.id ?? null,
|
|
role,
|
|
link
|
|
}
|
|
};
|
|
}
|
|
|
|
function sanitizeLinkJson(value) {
|
|
const input = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
const context = input.launchContext && typeof input.launchContext === "object" && !Array.isArray(input.launchContext) ? input.launchContext : {};
|
|
const execution = sanitizeExecutionContext(context.executionContext ?? context.execution);
|
|
return {
|
|
launchContext: {
|
|
source: "project-management",
|
|
projectId: safeToken(context.projectId, null),
|
|
taskRef: safeOpaqueValue(context.taskRef),
|
|
sourceId: safeToken(context.sourceId, null),
|
|
sourceKind: safeToken(context.sourceKind, null),
|
|
fileRef: safeToken(context.fileRef, null),
|
|
relativePath: safeRelativePath(context.relativePath),
|
|
taskId: safeToken(context.taskId, null),
|
|
title: shortText(context.title, 220),
|
|
status: safeToken(context.status, null),
|
|
hwpodId: execution?.hwpodId ?? safeOpaqueValue(context.hwpodId),
|
|
nodeId: execution?.nodeId ?? safeOpaqueValue(context.nodeId),
|
|
mdtodoRootRef: execution?.mdtodoRootRef ?? safeRelativePath(context.mdtodoRootRef),
|
|
workspaceRootRef: execution?.workspaceRootRef ?? safeWorkspaceRef(context.workspaceRootRef),
|
|
workspaceRootHash: execution?.workspaceRootHash ?? safeOpaqueValue(context.workspaceRootHash),
|
|
workspaceRootLabel: execution?.workspaceRootLabel ?? shortText(context.workspaceRootLabel, 180),
|
|
hwpodWorkspaceArgs: execution?.hwpodWorkspaceArgs ?? shortText(context.hwpodWorkspaceArgs, 500),
|
|
contextFingerprint: execution?.contextFingerprint ?? safeOpaqueValue(context.contextFingerprint),
|
|
capabilities: sanitizeCapabilities(context.capabilities),
|
|
executionContext: execution,
|
|
route: "/projects/mdtodo",
|
|
valuesRedacted: true
|
|
},
|
|
workbenchUrl: safeWorkbenchUrl(input.workbenchUrl),
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function buildMdtodoLaunchContext({ task, source, executionContext }) {
|
|
return {
|
|
source: "project-management",
|
|
projectId: task.projectId,
|
|
taskRef: task.taskRef,
|
|
sourceId: task.sourceId,
|
|
sourceKind: source.sourceKind ?? source.kind ?? null,
|
|
fileRef: task.fileRef,
|
|
relativePath: safeRelativePath(task.relativePath),
|
|
taskId: task.taskId,
|
|
title: shortText(task.title, 220),
|
|
status: safeToken(task.status, null),
|
|
hwpodId: executionContext.hwpodId,
|
|
nodeId: executionContext.nodeId,
|
|
workspaceRootRef: executionContext.workspaceRootRef,
|
|
workspaceRootHash: executionContext.workspaceRootHash,
|
|
workspaceRootLabel: executionContext.workspaceRootLabel,
|
|
mdtodoRootRef: executionContext.mdtodoRootRef,
|
|
hwpodWorkspaceArgs: executionContext.hwpodWorkspaceArgs,
|
|
contextFingerprint: executionContext.contextFingerprint,
|
|
capabilities: executionContext.capabilities,
|
|
executionContext,
|
|
route: "/projects/mdtodo",
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function buildMdtodoExecutionContext({ task, source }) {
|
|
const sourceKind = safeToken(source.sourceKind ?? source.kind, null);
|
|
const projectId = safeToken(task.projectId ?? source.projectId, null);
|
|
const taskRef = safeOpaqueValue(task.taskRef);
|
|
const sourceId = safeToken(task.sourceId ?? source.sourceId, null);
|
|
const fileRef = safeToken(task.fileRef, null);
|
|
if (!projectId || !taskRef || !sourceId || !fileRef) return { ok: false, status: 409, code: "mdtodo_launch_context_incomplete", message: "MDTODO launch context is missing task identity." };
|
|
const base = {
|
|
sourceId,
|
|
sourceKind,
|
|
projectId,
|
|
taskRef,
|
|
fileRef,
|
|
relativePath: safeRelativePath(task.relativePath),
|
|
taskId: safeToken(task.taskId, null),
|
|
valuesRedacted: true
|
|
};
|
|
if (sourceKind !== "hwpod-workspace") {
|
|
const context = { ...base, capabilities: sanitizeCapabilities(source.capabilities), contextFingerprint: contextFingerprint(base) };
|
|
return { ok: true, context };
|
|
}
|
|
const hwpodId = safeOpaqueValue(source.hwpodId);
|
|
const nodeId = safeOpaqueValue(source.nodeId);
|
|
const workspaceRootRef = safeWorkspaceRef(source.workspaceRootRef);
|
|
const mdtodoRootRef = safeRelativePath(source.mdtodoRootRef) ?? ".";
|
|
if (!hwpodId || !nodeId || !workspaceRootRef) {
|
|
return { ok: false, status: 409, code: "hwpod_launch_context_missing", message: "HWPOD MDTODO source is missing hwpodId, nodeId or workspaceRootRef." };
|
|
}
|
|
const hwpodWorkspaceArgs = `--hwpod-id ${hwpodId} --workspace-path ${promptShellArg(workspaceRootRef)}`;
|
|
const contextBase = {
|
|
...base,
|
|
hwpodId,
|
|
nodeId,
|
|
workspaceRootRef,
|
|
workspaceRootHash: hashText(workspaceRootRef),
|
|
workspaceRootLabel: workspaceRootLabel(workspaceRootRef),
|
|
mdtodoRootRef,
|
|
hwpodWorkspaceArgs,
|
|
capabilities: sanitizeCapabilities(source.capabilities),
|
|
valuesRedacted: true
|
|
};
|
|
return { ok: true, context: { ...contextBase, contextFingerprint: contextFingerprint(contextBase) } };
|
|
}
|
|
|
|
function sourceSummary(source) {
|
|
return {
|
|
sourceId: source.sourceId,
|
|
sourceKind: source.sourceKind ?? source.kind ?? null,
|
|
projectId: source.projectId ?? null,
|
|
hwpodId: safeOpaqueValue(source.hwpodId),
|
|
nodeId: safeOpaqueValue(source.nodeId),
|
|
mdtodoRootRef: safeRelativePath(source.mdtodoRootRef),
|
|
workspaceRootHash: source.workspaceRootRef ? hashText(source.workspaceRootRef) : null,
|
|
workspaceRootLabel: source.workspaceRootRef ? workspaceRootLabel(source.workspaceRootRef) : null,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function sanitizeExecutionContext(value) {
|
|
const input = value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
if (!input) return null;
|
|
const sourceId = safeToken(input.sourceId, null);
|
|
const taskRef = safeOpaqueValue(input.taskRef);
|
|
return {
|
|
sourceId,
|
|
sourceKind: safeToken(input.sourceKind, null),
|
|
projectId: safeToken(input.projectId, null),
|
|
taskRef,
|
|
fileRef: safeToken(input.fileRef, null),
|
|
relativePath: safeRelativePath(input.relativePath),
|
|
taskId: safeToken(input.taskId, null),
|
|
hwpodId: safeOpaqueValue(input.hwpodId),
|
|
nodeId: safeOpaqueValue(input.nodeId),
|
|
workspaceRootRef: safeWorkspaceRef(input.workspaceRootRef),
|
|
workspaceRootHash: safeOpaqueValue(input.workspaceRootHash),
|
|
workspaceRootLabel: shortText(input.workspaceRootLabel, 180),
|
|
mdtodoRootRef: safeRelativePath(input.mdtodoRootRef),
|
|
hwpodWorkspaceArgs: shortText(input.hwpodWorkspaceArgs, 500),
|
|
contextFingerprint: safeOpaqueValue(input.contextFingerprint),
|
|
capabilities: sanitizeCapabilities(input.capabilities),
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function sanitizeCapabilities(value) {
|
|
const input = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
const out = {};
|
|
for (const [key, item] of Object.entries(input)) {
|
|
if (!/^[A-Za-z0-9_.:-]{1,80}$/u.test(key)) continue;
|
|
if (["string", "number", "boolean"].includes(typeof item) || item === null) out[key] = item;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function contextFingerprint(value) {
|
|
return `ctx_${hashText(JSON.stringify(value)).slice(0, 24)}`;
|
|
}
|
|
|
|
function hashText(value) {
|
|
return createHash("sha256").update(String(value ?? "")).digest("hex");
|
|
}
|
|
|
|
function promptShellArg(value) {
|
|
return `'${String(value ?? "").replace(/'/gu, `'\\''`)}'`;
|
|
}
|
|
|
|
function workspaceRootLabel(value) {
|
|
const normalized = String(value ?? "").replace(/\\/gu, "/").replace(/\/+$/u, "");
|
|
return normalized.split("/").filter(Boolean).pop() || normalized || null;
|
|
}
|
|
|
|
function stableLinkId({ projectId, taskRef, sessionId }) {
|
|
return `lnk_${createHash("sha256").update(`${projectId}\n${taskRef}\n${sessionId}`).digest("hex").slice(0, 24)}`;
|
|
}
|
|
|
|
async function recordAuditEvent(store, requestOrContext, event) {
|
|
if (typeof store.recordAuditEvent !== "function") return;
|
|
const actor = requestOrContext instanceof Request ? actorFromRequest(requestOrContext) : requestOrContext?.actor ?? null;
|
|
await store.recordAuditEvent({
|
|
eventId: `aud_${randomUUID()}`,
|
|
actorId: actor?.id ?? null,
|
|
action: event.action,
|
|
targetType: event.targetType,
|
|
targetId: event.targetId,
|
|
outcome: event.outcome,
|
|
event,
|
|
valuesRedacted: true
|
|
});
|
|
}
|
|
|
|
function matchSourceAction(pathname) {
|
|
const match = /^\/v1\/project-management\/mdtodo\/sources\/([A-Za-z0-9_.:-]{1,160})(?:\/(probe|reindex))?$/u.exec(pathname);
|
|
return match ? { sourceId: match[1], action: match[2] ?? null } : null;
|
|
}
|
|
|
|
function matchFileContent(pathname) {
|
|
const match = /^\/v1\/project-management\/mdtodo\/files\/([A-Za-z0-9_.:-]{1,160})\/content$/u.exec(pathname);
|
|
return match ? { fileRef: match[1] } : null;
|
|
}
|
|
|
|
function matchTaskAction(pathname) {
|
|
const match = /^\/v1\/project-management\/mdtodo\/tasks\/(.+)$/u.exec(pathname);
|
|
return match ? { taskRef: decodeURIComponent(match[1]) } : null;
|
|
}
|
|
|
|
function safeOpaqueValue(value) {
|
|
const text = String(value ?? "").trim();
|
|
return /^[A-Za-z0-9_.:/#_-]{1,360}$/u.test(text) ? text : null;
|
|
}
|
|
|
|
function safeWorkbenchUrl(value) {
|
|
const text = String(value ?? "").trim();
|
|
return /^\/workbench\/sessions\/[^/?#]+$/u.test(text) ? text : null;
|
|
}
|
|
|
|
function safeRelativePath(value) {
|
|
const text = String(value ?? "").replace(/\\/gu, "/").trim().replace(/^\/+|\/+$/gu, "");
|
|
if (!text) return null;
|
|
if (text === ".") return ".";
|
|
if (text.includes("..") || /(^|\/)\./u.test(text)) return null;
|
|
return /^[\p{L}\p{N}_./ -]{1,360}$/u.test(text) ? text : null;
|
|
}
|
|
|
|
function safeWorkspaceRef(value) {
|
|
const text = String(value ?? "").trim();
|
|
if (!text || text.includes("\0") || text.length > 500) return null;
|
|
return /^[\p{L}\p{N}_:./\\ -]+$/u.test(text) ? text : null;
|
|
}
|
|
|
|
function shortText(value, max) {
|
|
const text = String(value ?? "").replace(/\s+/gu, " ").trim();
|
|
return text ? text.slice(0, max) : null;
|
|
}
|
|
|
|
function json(status, payload) {
|
|
return new Response(`${JSON.stringify(payload)}\n`, {
|
|
status,
|
|
headers: {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"cache-control": "no-store"
|
|
}
|
|
});
|
|
}
|
|
|
|
function jsonError(status, code, message) {
|
|
return json(status, { ok: false, contractVersion, serviceId, error: { code, message, status }, valuesRedacted: true });
|
|
}
|
|
|
|
function queryToken(url, name) {
|
|
const value = url.searchParams.get(name);
|
|
return /^[A-Za-z0-9_.:-]{1,160}$/u.test(String(value ?? "")) ? value : null;
|
|
}
|
|
|
|
function queryOpaque(url, name) {
|
|
const value = url.searchParams.get(name);
|
|
return /^[A-Za-z0-9_.:/#_-]{1,320}$/u.test(String(value ?? "")) ? value : null;
|
|
}
|
|
|
|
function querySearch(url, name) {
|
|
const value = url.searchParams.get(name);
|
|
if (value === null) return null;
|
|
const text = shortText(value, 120);
|
|
return text && /^[\p{L}\p{N}\s_.:/#-]{1,120}$/u.test(text) ? text : null;
|
|
}
|
|
|
|
function safeToken(value, fallback) {
|
|
const text = String(value ?? "").trim();
|
|
return /^[A-Za-z0-9_.:-]{1,96}$/u.test(text) ? text : fallback;
|
|
}
|
|
|
|
function positiveInteger(value, fallback) {
|
|
const number = Number(value);
|
|
return Number.isInteger(number) && number > 0 ? number : fallback;
|
|
}
|
|
|
|
function nonNegativeInteger(value, fallback) {
|
|
const number = Number(value);
|
|
return Number.isInteger(number) && number >= 0 ? number : fallback;
|
|
}
|
|
|
|
function taskListPage(url, options) {
|
|
const limit = Math.min(options.maxLimit, positiveInteger(url.searchParams.get("limit"), options.defaultLimit));
|
|
return {
|
|
offset: nonNegativeInteger(url.searchParams.get("offset"), 0),
|
|
limit,
|
|
maxLimit: options.maxLimit
|
|
};
|
|
}
|