diff --git a/apps/pi-extension/server/reference.test.ts b/apps/pi-extension/server/reference.test.ts
new file mode 100644
index 000000000..4faa68271
--- /dev/null
+++ b/apps/pi-extension/server/reference.test.ts
@@ -0,0 +1,175 @@
+/**
+ * /api/doc containment on the Node transport. Each runtime resolves and gates
+ * paths in its own handler, so both carry the full set of escape vectors.
+ */
+import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
+import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { createServer, type Server } from "node:http";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { handleDocExistsRequest, handleDocRequest } from "./reference.ts";
+import { requestUrl } from "./helpers.ts";
+
+const tempDirs: string[] = [];
+let currentRoots: string[] = [];
+
+function makeTempDir(prefix: string): string {
+ const dir = mkdtempSync(join(tmpdir(), prefix));
+ tempDirs.push(dir);
+ return dir;
+}
+
+function writeTempFile(root: string, relativePath: string, content: string): string {
+ const full = join(root, relativePath);
+ mkdirSync(dirname(full), { recursive: true });
+ writeFileSync(full, content);
+ return full;
+}
+
+function makeEscapeFixture(): { root: string; outside: string } {
+ const root = makeTempDir("pi-symlink-root-");
+ const outside = makeTempDir("pi-symlink-outside-");
+ writeTempFile(outside, "secret.md", "SECRET-MD\n");
+ writeTempFile(outside, "secret.html", "
SECRET-HTML
");
+ writeTempFile(outside, "secret.ts", "// SECRET-TS\n");
+ writeTempFile(outside, "deep.md", "SECRET-DEEP\n");
+ writeTempFile(root, "sibling.md", "sibling\n");
+ symlinkSync(join(outside, "secret.md"), join(root, "link.md"));
+ symlinkSync(join(outside, "secret.html"), join(root, "link.html"));
+ symlinkSync(join(outside, "secret.ts"), join(root, "link.ts"));
+ symlinkSync(outside, join(root, "linkdir"));
+ currentRoots = [root];
+ return { root, outside };
+}
+
+describe("pi /api/doc containment", () => {
+ let server: Server;
+ let base = "";
+
+ beforeAll(async () => {
+ server = createServer(async (req, res) => {
+ const url = requestUrl(req);
+ if (url.pathname === "/api/doc") {
+ await handleDocRequest(res, url, { rootPaths: currentRoots });
+ return;
+ }
+ if (url.pathname === "/api/doc/exists") {
+ await handleDocExistsRequest(res, req, { rootPaths: currentRoots });
+ return;
+ }
+ res.writeHead(404);
+ res.end();
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("no port");
+ base = `http://127.0.0.1:${address.port}`;
+ });
+
+ afterAll(() => {
+ server.close();
+ });
+
+ afterEach(() => {
+ for (const dir of tempDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ const getDoc = (path: string, baseDir?: string) => {
+ const url = new URL(`${base}/api/doc`);
+ url.searchParams.set("path", path);
+ if (baseDir) url.searchParams.set("base", baseDir);
+ return fetch(url);
+ };
+
+ const escapeVectors: { branch: string; path: (root: string) => string; withBase?: boolean }[] = [
+ { branch: "base-relative document", path: () => "link.md", withBase: true },
+ { branch: "markdown resolver, bare name", path: () => "link.md" },
+ { branch: "markdown resolver, absolute path", path: (root) => join(root, "link.md") },
+ { branch: "raw HTML", path: () => "link.html" },
+ { branch: "code file", path: () => "link.ts" },
+ { branch: "symlinked directory", path: () => "linkdir/deep.md" },
+ ];
+
+ for (const vector of escapeVectors) {
+ test(`denies an escaping symlink via the ${vector.branch} branch`, async () => {
+ const { root } = makeEscapeFixture();
+
+ const res = await getDoc(vector.path(root), vector.withBase ? root : undefined);
+
+ expect(res.status).toBe(403);
+ expect(await res.text()).not.toContain("SECRET");
+ });
+ }
+
+ test("ordinary in-root siblings are still served", async () => {
+ makeEscapeFixture();
+
+ const res = await getDoc("sibling.md");
+ const data = await res.json() as { markdown?: string };
+
+ expect(res.status).toBe(200);
+ expect(data.markdown).toBe("sibling\n");
+ });
+
+ test("doc/exists reports an escaping symlink as missing, not found", async () => {
+ makeEscapeFixture();
+
+ const res = await fetch(`${base}/api/doc/exists`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ paths: ["link.ts"] }),
+ });
+ const data = await res.json() as { results: Record };
+
+ expect(data.results["link.ts"]).toEqual({ status: "missing" });
+ });
+
+ test("refuses a base directory that reaches outside the root through a symlink", async () => {
+ const { root } = makeEscapeFixture();
+
+ const res = await getDoc("deep.md", join(root, "linkdir"));
+
+ expect(res.status).toBe(404);
+ expect(await res.text()).not.toContain("SECRET");
+ });
+
+ test("answers 403 for an escaping absolute path whether or not it exists", async () => {
+ const { outside } = makeEscapeFixture();
+
+ const existing = await getDoc(join(outside, "secret.md"));
+ const absent = await getDoc(join(outside, "absent.md"));
+
+ expect(existing.status).toBe(403);
+ expect(absent.status).toBe(403);
+ });
+
+ test("serves a bare filename from a root given as a symlink", async () => {
+ const real = makeTempDir("pi-symroot-real-");
+ const parent = makeTempDir("pi-symroot-parent-");
+ const link = join(parent, "docs");
+ writeTempFile(real, "note.md", "note\n");
+ symlinkSync(real, link);
+ currentRoots = [link];
+
+ const res = await getDoc("note.md");
+ const data = await res.json() as { markdown?: string };
+
+ expect(res.status).toBe(200);
+ expect(data.markdown).toBe("note\n");
+ });
+
+ test("rejects an oversized .html with 413 whether or not a base is given", async () => {
+ const root = makeTempDir("pi-html-cap-");
+ currentRoots = [root];
+ writeFileSync(join(root, "huge.html"), `${"x".repeat(2 * 1024 * 1024 + 1)}
`);
+
+ const withoutBase = await getDoc("huge.html");
+ const withBase = await getDoc("huge.html", root);
+
+ expect(withoutBase.status).toBe(413);
+ expect(withBase.status).toBe(413);
+ expect((await withBase.json() as { error?: string }).error).toBe("File too large (max 2MB)");
+ });
+});
diff --git a/apps/pi-extension/server/reference.ts b/apps/pi-extension/server/reference.ts
index fb690b2b8..4407c74cc 100644
--- a/apps/pi-extension/server/reference.ts
+++ b/apps/pi-extension/server/reference.ts
@@ -30,18 +30,27 @@ import {
} from "../generated/workspace-status.ts";
import { detectObsidianVaults } from "../generated/integrations-common.ts";
import {
- isAbsoluteUserPath,
- isCodeFilePath,
- resolveCodeFile,
- resolveMarkdownFile,
resolveUserPath,
- isWithinProjectRoot,
warmFileListCache,
getAnnotatableDocRegex,
MAX_ANNOTATABLE_FILE_BYTES,
isAnnotatableTextPath,
} from "../generated/resolve-file.ts";
-import { parseCodePath } from "../generated/code-file.ts";
+import {
+ DOC_ACCESS_DENIED,
+ DOC_TOO_LARGE,
+ docResolutionError,
+ getAllowedRootPaths,
+ getTrustedBaseDir,
+ isPathAllowed,
+ relativizeToAllowedRoots,
+ resolveAllowedDocPath,
+ resolveCodeFileInRoots,
+ resolveDocTarget,
+ type DocErrorPayload,
+ type ResolveAllowedDocPathResult,
+} from "../generated/doc-resolve.ts";
+import { parseCodePath, type ParsedCodePath } from "../generated/code-file.ts";
import { htmlToMarkdown } from "../generated/html-to-markdown.ts";
import { disabledSourceSave, type SourceFileSnapshot, type SourceSaveCapability } from "../generated/source-save.ts";
import {
@@ -114,124 +123,11 @@ interface HandleDocExistsOptions {
rootPaths?: string[];
}
-type RouteResolveResult =
- | { kind: "found"; path: string }
- | { kind: "not_found"; input: string }
- | { kind: "ambiguous"; input: string; matches: string[] }
- | { kind: "unavailable"; input: string };
-
-function getAllowedRootPaths(options?: { rootPath?: string; rootPaths?: string[] }): string[] {
- const rawRoots = options?.rootPaths?.length
- ? options.rootPaths
- : [options?.rootPath ?? process.cwd()];
- const roots: string[] = [];
- for (const root of rawRoots) {
- if (typeof root !== "string" || root.length === 0) continue;
- const resolved = resolveUserPath(root);
- if (!roots.includes(resolved)) roots.push(resolved);
- }
- return roots.length > 0 ? roots : [resolveUserPath(process.cwd())];
-}
-
-function isWithinAllowedRoots(candidate: string, roots: string[]): boolean {
- return roots.some((root) => isWithinProjectRoot(candidate, root));
-}
-
-function getTrustedBaseDir(base: string | null, roots: string[]): string | null {
- if (!base) return null;
- const resolvedBase = resolveUserPath(base);
- return isWithinAllowedRoots(resolvedBase, roots) ? resolvedBase : null;
-}
-
-export type ResolveAllowedDocPathResult =
- | { kind: "resolved"; path: string }
- | { kind: "denied" };
-
-/**
- * Resolve a client-supplied path the same way /api/doc's base-relative and
- * absolute branches do (see `getTrustedBaseDir` / `isWithinAllowedRoots`
- * above), for callers that need the canonical contained path without
- * reading the file — namely the annotate version endpoints, which derive a
- * history slug from the resolved path rather than trusting a client-supplied
- * slug (a client slug would be a path-traversal vector: the history dir
- * lookup joins it into a filesystem path unsanitized). Mirrors
- * packages/server/reference-handlers.ts.
- */
-export function resolveAllowedDocPath(
- requestedPath: string,
- base: string | null,
- options?: { rootPaths?: string[] },
-): ResolveAllowedDocPathResult {
- const allowedRoots = getAllowedRootPaths(options);
- const resolvedBase = getTrustedBaseDir(base, allowedRoots);
- const candidate = resolveUserPath(requestedPath, resolvedBase ?? undefined);
- return isWithinAllowedRoots(candidate, allowedRoots)
- ? { kind: "resolved", path: candidate }
- : { kind: "denied" };
-}
-
-function relativizeToAllowedRoots(path: string, roots: string[]): string {
- for (const root of roots) {
- const prefix = `${root}/`;
- if (path.startsWith(prefix)) return path.slice(prefix.length);
- if (path === root) return ".";
- }
- return path;
-}
-
-async function resolveCodeFileFromAllowedRoots(
- input: string,
- roots: string[],
- baseDir: string | null,
-): Promise {
- const found = new Set();
- const ambiguous = new Set();
- let unavailable = false;
+// Re-exported for the annotate version endpoints, which import it from here.
+export { resolveAllowedDocPath, type ResolveAllowedDocPathResult };
- for (const root of roots) {
- const rootBase = baseDir && isWithinProjectRoot(baseDir, root) ? baseDir : undefined;
- const result = await resolveCodeFile(input, root, rootBase);
- if (result.kind === "found") {
- if (isWithinProjectRoot(result.path, root)) found.add(result.path);
- } else if (result.kind === "ambiguous") {
- for (const match of result.matches) {
- ambiguous.add(match);
- }
- } else if (result.kind === "unavailable") {
- unavailable = true;
- }
- }
-
- if (found.size === 1) return { kind: "found", path: [...found][0] };
- if (found.size > 1) return { kind: "ambiguous", input, matches: [...found] };
- if (ambiguous.size > 0) return { kind: "ambiguous", input, matches: [...ambiguous] };
- if (unavailable) return { kind: "unavailable", input };
- return { kind: "not_found", input };
-}
-
-function resolveMarkdownFileFromAllowedRoots(input: string, roots: string[]): RouteResolveResult {
- const found = new Set();
- const ambiguous = new Set();
- let unavailable = false;
-
- for (const root of roots) {
- const result = resolveMarkdownFile(input, root);
- if (result.kind === "found") {
- if (isWithinProjectRoot(result.path, root)) found.add(result.path);
- } else if (result.kind === "ambiguous") {
- for (const match of result.matches) {
- ambiguous.add(match);
- }
- } else if (result.kind === "unavailable") {
- unavailable = true;
- }
- }
-
- if (found.size === 1) return { kind: "found", path: [...found][0] };
- if (found.size > 1) return { kind: "ambiguous", input, matches: [...found] };
- if (ambiguous.size > 0) return { kind: "ambiguous", input, matches: [...ambiguous] };
- if (unavailable) return { kind: "unavailable", input };
- return { kind: "not_found", input };
+function sendDocError(res: Res, payload: DocErrorPayload): void {
+ json(res, payload.body, payload.status);
}
type DocOptionsResult = T & {
@@ -357,7 +253,53 @@ function includeWorkspaceFile(relativePath: string, _change: WorkspaceFileChange
return getAnnotatableDocRegex().test(relativePath) && !isFileBrowserExcludedPath(relativePath);
}
-/** Serve a linked markdown document. Uses shared resolveMarkdownFile for parity with Bun server. */
+/** The render decision is a pure function of resolved path plus `?convert=1`. */
+function readDocument(res: Res, path: string, convert: boolean, options: HandleDocOptions): void {
+ try {
+ if (statSync(path).size > MAX_ANNOTATABLE_FILE_BYTES) {
+ sendDocError(res, DOC_TOO_LARGE);
+ return;
+ }
+ const snapshot = readSourceFileSnapshot(path);
+ if (/\.html?$/i.test(path)) {
+ if (convert) {
+ jsonDoc(res, { markdown: htmlToMarkdown(snapshot.text), filepath: path, isConverted: true, renderAs: "markdown" }, options);
+ } else {
+ jsonDoc(res, { rawHtml: snapshot.text, renderAs: "html", filepath: path }, options);
+ }
+ return;
+ }
+ jsonDoc(res, { markdown: snapshot.text, filepath: path, renderAs: "markdown" }, options, undefined, snapshot);
+ } catch {
+ json(res, { error: "Failed to read file" }, 500);
+ }
+}
+
+async function readCodeFile(res: Res, path: string, input: string, parsed: ParsedCodePath): Promise {
+ try {
+ if (statSync(path).size > MAX_ANNOTATABLE_FILE_BYTES) {
+ sendDocError(res, DOC_TOO_LARGE);
+ return;
+ }
+ const contents = readFileSync(path, "utf-8");
+ const displayName = path.split("/").pop() || path;
+ let prerenderedHTML: string | undefined;
+ try {
+ const result = await preloadFile({
+ file: { name: displayName, contents },
+ options: { disableFileHeader: true },
+ });
+ prerenderedHTML = result.prerenderedHTML;
+ } catch {
+ // Fall back to client-side rendering
+ }
+ json(res, { codeFile: true, contents, filepath: path, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd });
+ } catch {
+ json(res, { error: `File not found: ${input}` }, 404);
+ }
+}
+
+/** Uses the shared doc resolver for parity with the Bun server. */
export async function handleDocRequest(res: Res, url: URL, options: HandleDocOptions = {}): Promise {
const requestedPath = url.searchParams.get("path");
if (!requestedPath) {
@@ -371,183 +313,36 @@ export async function handleDocRequest(res: Res, url: URL, options: HandleDocOpt
void warmFileListCache(root, "code");
}
- // Try resolving relative to base directory first (used by annotate mode).
- const base = url.searchParams.get("base");
- const resolvedBase = getTrustedBaseDir(base, allowedRoots);
+ // A base is only honored when it is itself inside an allowed root.
+ const resolvedBase = getTrustedBaseDir(url.searchParams.get("base"), allowedRoots);
const convert = url.searchParams.get("convert") === "1";
// `?doc=1` (set by the file browser) forces annotatable plain-text rendering
// for extensions that overlap CODE_FILE_REGEX (.yaml, .json, .toml, .ini,
// .xml). Without it, those paths keep the syntax-highlighted code-file
// popout response, so code-file links inside documents are unaffected.
const forceDoc = url.searchParams.get("doc") === "1";
- const docExtensions = getAnnotatableDocRegex();
- const wantsDocRender = (path: string) =>
- docExtensions.test(path) && (forceDoc || !isCodeFilePath(path));
- if (
- resolvedBase &&
- !isAbsoluteUserPath(requestedPath) &&
- wantsDocRender(requestedPath)
- ) {
- const fromBase = resolveUserPath(requestedPath, resolvedBase);
- if (!isWithinAllowedRoots(fromBase, allowedRoots)) {
- json(res, { error: "Access denied: path is outside project root" }, 403);
- return;
- }
- try {
- if (existsSync(fromBase)) {
- if (statSync(fromBase).size > MAX_ANNOTATABLE_FILE_BYTES) {
- json(res, { error: "File too large (max 2MB)" }, 413);
- return;
- }
- const snapshot = readSourceFileSnapshot(fromBase);
- const raw = snapshot.text;
- const isHtml = /\.html?$/i.test(requestedPath);
- if (isHtml && !convert) {
- jsonDoc(res, { rawHtml: raw, renderAs: "html", filepath: fromBase }, options);
- return;
- }
- const markdown = isHtml ? htmlToMarkdown(raw) : raw;
- jsonDoc(
- res,
- { markdown, filepath: fromBase, isConverted: isHtml, renderAs: "markdown" },
- options,
- undefined,
- isHtml ? undefined : snapshot,
- );
- return;
- }
- } catch {
- /* fall through to standard resolution */
- }
- }
-
- // HTML files: resolve directly (not via resolveMarkdownFile which only handles .md/.mdx)
- const projectRoot = allowedRoots[0];
- if (/\.html?$/i.test(requestedPath)) {
- const resolvedHtml = resolveUserPath(requestedPath, resolvedBase || projectRoot);
- if (!isWithinAllowedRoots(resolvedHtml, allowedRoots)) {
- json(res, { error: "Access denied: path is outside project root" }, 403);
- return;
- }
- try {
- if (existsSync(resolvedHtml)) {
- const html = readFileSync(resolvedHtml, "utf-8");
- if (!convert) {
- jsonDoc(res, { rawHtml: html, renderAs: "html", filepath: resolvedHtml }, options);
- return;
- }
- jsonDoc(res, { markdown: htmlToMarkdown(html), filepath: resolvedHtml, isConverted: true, renderAs: "markdown" }, options);
- return;
- }
- } catch { /* fall through to 404 */ }
- json(res, { error: `File not found: ${requestedPath}` }, 404);
- return;
- }
-
- // Code files: try literal resolve first; on miss, fall back to smart resolver.
- // Skipped when the client asked for doc rendering (`?doc=1`) on an
- // annotatable plain-text path — those fall through to the markdown
- // resolution below and render like .txt.
- if (isCodeFilePath(requestedPath) && !(forceDoc && isAnnotatableTextPath(requestedPath))) {
- const parsed = parseCodePath(requestedPath);
- const cleanPath = parsed.filePath;
- const literalPath = resolveUserPath(cleanPath, resolvedBase || projectRoot);
- const literalAllowed = isWithinAllowedRoots(literalPath, allowedRoots);
-
- let resolvedCode: string | null = null;
- if (literalAllowed && existsSync(literalPath)) {
- resolvedCode = literalPath;
- }
-
- if (!resolvedCode) {
- if (isAbsoluteUserPath(cleanPath) && !isWithinAllowedRoots(resolveUserPath(cleanPath), allowedRoots)) {
- json(res, { error: "Access denied: path is outside project root" }, 403);
- return;
- }
- const result = await resolveCodeFileFromAllowedRoots(cleanPath, allowedRoots, resolvedBase);
- if (result.kind === "found") {
- resolvedCode = result.path;
- } else if (result.kind === "ambiguous") {
- const relative = result.matches.map((m: string) => relativizeToAllowedRoots(m, allowedRoots));
- json(res, { error: `Ambiguous path '${requestedPath}'`, matches: relative }, 400);
- return;
- } else if (result.kind === "unavailable") {
- json(res, { error: `Cannot scan project: ${requestedPath}`, reason: "unavailable" }, 503);
- return;
- } else {
- json(res, { error: `File not found: ${requestedPath}` }, 404);
- return;
- }
- if (!isWithinAllowedRoots(resolvedCode, allowedRoots)) {
- json(res, { error: "Access denied: path is outside project root" }, 403);
- return;
- }
- }
- try {
- const stat = statSync(resolvedCode);
- if (stat.size > MAX_ANNOTATABLE_FILE_BYTES) {
- json(res, { error: "File too large (max 2MB)" }, 413);
- return;
- }
- const contents = readFileSync(resolvedCode, "utf-8");
- const displayName = resolvedCode.split("/").pop() || resolvedCode;
- let prerenderedHTML: string | undefined;
- try {
- const result = await preloadFile({
- file: { name: displayName, contents },
- options: { disableFileHeader: true },
- });
- prerenderedHTML = result.prerenderedHTML;
- } catch {
- // Fall back to client-side rendering
- }
- json(res, { codeFile: true, contents, filepath: resolvedCode, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd });
- return;
- } catch {
- json(res, { error: `File not found: ${requestedPath}` }, 404);
- return;
- }
- }
-
- if (isAbsoluteUserPath(requestedPath) && !isWithinAllowedRoots(resolveUserPath(requestedPath), allowedRoots)) {
- json(res, { error: "Access denied: path is outside project root" }, 403);
+ const resolution = await resolveDocTarget(requestedPath, {
+ base: resolvedBase,
+ forceDoc,
+ roots: allowedRoots,
+ });
+ // The one authorization site. A named path is gated whether or not it
+ // exists, so an escaping name is denied rather than reported absent.
+ const named = resolution.kind === "file" || resolution.kind === "not_found" ? resolution.path : undefined;
+ if (named !== undefined && !isPathAllowed(named, allowedRoots)) {
+ sendDocError(res, DOC_ACCESS_DENIED);
return;
}
- const result = resolveMarkdownFileFromAllowedRoots(requestedPath, allowedRoots);
-
- if (result.kind === "ambiguous") {
- json(
- res,
- {
- error: `Ambiguous filename '${result.input}': found ${result.matches.length} matches`,
- matches: result.matches.map((m: string) => relativizeToAllowedRoots(m, allowedRoots)),
- },
- 400,
- );
+ if (resolution.kind !== "file") {
+ sendDocError(res, docResolutionError(resolution, allowedRoots));
return;
}
-
- if (result.kind === "unavailable") {
- json(res, { error: `Cannot scan project: ${result.input}`, reason: "unavailable" }, 503);
+ if (resolution.render === "code") {
+ await readCodeFile(res, resolution.path, requestedPath, resolution.code);
return;
}
-
- if (result.kind === "not_found") {
- json(res, { error: `File not found: ${result.input}` }, 404);
- return;
- }
-
- try {
- if (statSync(result.path).size > MAX_ANNOTATABLE_FILE_BYTES) {
- json(res, { error: "File too large (max 2MB)" }, 413);
- return;
- }
- const snapshot = readSourceFileSnapshot(result.path);
- jsonDoc(res, { markdown: snapshot.text, filepath: result.path, renderAs: "markdown" }, options, undefined, snapshot);
- } catch {
- json(res, { error: "Failed to read file" }, 500);
- }
+ readDocument(res, resolution.path, convert, options);
}
/**
@@ -580,14 +375,9 @@ export async function handleDocExistsRequest(res: Res, req: IncomingMessage, opt
await Promise.all(
(paths as string[]).map(async (p) => {
- const cleanP = parseCodePath(p).filePath;
- if (isAbsoluteUserPath(cleanP) && !isWithinAllowedRoots(resolveUserPath(cleanP), allowedRoots)) {
- results[p] = { status: "missing" };
- return;
- }
- const r = await resolveCodeFileFromAllowedRoots(cleanP, allowedRoots, baseDir);
+ const r = await resolveCodeFileInRoots(parseCodePath(p).filePath, allowedRoots, baseDir);
if (r.kind === "found") {
- results[p] = isWithinAllowedRoots(r.path, allowedRoots)
+ results[p] = isPathAllowed(r.path, allowedRoots)
? { status: "found", resolved: r.path }
: { status: "missing" };
} else if (r.kind === "ambiguous") {
diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh
index 9c3f9b901..b3f8c739f 100755
--- a/apps/pi-extension/vendor.sh
+++ b/apps/pi-extension/vendor.sh
@@ -29,7 +29,7 @@ for f in config-types storage-types workspace-status-types; do
done
# Everything else in the original flat list stays sourced from packages/shared.
-for f in prompts review-core generated-files feedback-archive cli-pagination jj-core gitbutler-core vcs-core review-args draft annotate-history pr-types pr-context-live pr-artifact-document pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common markdown-extensions resolve-file file-browser-watch-core annotate-reference-roots-node worktree worktree-pool html-to-markdown html-diff html-assets html-assets-node url-to-markdown tour annotate-args annotate-target at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff call-flow-types call-flow-languages call-flow-pack-locks call-flow-install-lock call-flow call-flow-install single-flight source-save-node review-profiles guide-store guide-instructions-store commit-avatars commit-history port-range annotate-client-lease annotate-decision archive-mode tailscale live-proxy-core live-probe live-proxy-node; do
+for f in prompts review-core generated-files feedback-archive cli-pagination jj-core gitbutler-core vcs-core review-args draft annotate-history pr-types pr-context-live pr-artifact-document pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common markdown-extensions resolve-file doc-resolve file-browser-watch-core annotate-reference-roots-node worktree worktree-pool html-to-markdown html-diff html-assets html-assets-node url-to-markdown tour annotate-args annotate-target at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff call-flow-types call-flow-languages call-flow-pack-locks call-flow-install-lock call-flow call-flow-install single-flight source-save-node review-profiles guide-store guide-instructions-store commit-avatars commit-history port-range annotate-client-lease annotate-decision archive-mode tailscale live-proxy-core live-probe live-proxy-node; do
src="../../packages/shared/$f.ts"
# Shared modules that import browser-safe siblings from @plannotator/core
# (e.g. guide-store → core/guide-format): generated/ is flat and vendors the
diff --git a/packages/server/reference-handlers.test.ts b/packages/server/reference-handlers.test.ts
index 4fb41a250..f9c0f3dd5 100644
--- a/packages/server/reference-handlers.test.ts
+++ b/packages/server/reference-handlers.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
-import { mkdtempSync, mkdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
+import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
@@ -352,3 +352,142 @@ describe("annotatable document size cap", () => {
expect(data.error).toBe("File too large (max 2MB)");
});
});
+
+// Each branch resolves its own path, so a symlink escaping the root has to be
+// denied on all six. A single-vector test passes against a fix that missed one.
+describe("symlink containment", () => {
+ function makeEscapeFixture(): { root: string; outside: string } {
+ const root = makeTempDir("plannotator-symlink-root-");
+ const outside = makeTempDir("plannotator-symlink-outside-");
+ writeTempFile(outside, "secret.md", "SECRET-MD\n");
+ writeTempFile(outside, "secret.html", "SECRET-HTML
");
+ writeTempFile(outside, "secret.ts", "// SECRET-TS\n");
+ writeTempFile(outside, "deep.md", "SECRET-DEEP\n");
+ writeTempFile(root, "sibling.md", "sibling\n");
+ symlinkSync(join(outside, "secret.md"), join(root, "link.md"));
+ symlinkSync(join(outside, "secret.html"), join(root, "link.html"));
+ symlinkSync(join(outside, "secret.ts"), join(root, "link.ts"));
+ symlinkSync(outside, join(root, "linkdir"));
+ return { root, outside };
+ }
+
+ const escapeVectors: { branch: string; path: (root: string) => string; withBase?: boolean }[] = [
+ { branch: "base-relative document", path: () => "link.md", withBase: true },
+ { branch: "markdown resolver, bare name", path: () => "link.md" },
+ { branch: "markdown resolver, absolute path", path: (root) => join(root, "link.md") },
+ { branch: "raw HTML", path: () => "link.html" },
+ { branch: "code file", path: () => "link.ts" },
+ { branch: "symlinked directory", path: () => "linkdir/deep.md" },
+ ];
+
+ for (const vector of escapeVectors) {
+ test(`denies an escaping symlink via the ${vector.branch} branch`, async () => {
+ const { root } = makeEscapeFixture();
+
+ const res = await getDoc(vector.path(root), {
+ rootPaths: [root],
+ base: vector.withBase ? root : undefined,
+ });
+
+ expect(res.status).toBe(403);
+ expect(await res.text()).not.toContain("SECRET");
+ });
+ }
+
+ test("ordinary in-root siblings are still served", async () => {
+ const { root } = makeEscapeFixture();
+
+ const res = await getDoc("sibling.md", { rootPaths: [root] });
+ const data = await res.json() as { markdown?: string };
+
+ expect(res.status).toBe(200);
+ expect(data.markdown).toBe("sibling\n");
+ });
+
+ test("doc/exists reports an escaping symlink as missing, not found", async () => {
+ const { root } = makeEscapeFixture();
+
+ const data = await postDocExists({ paths: ["link.ts"] }, { rootPath: root });
+
+ expect(data.results["link.ts"]).toEqual({ status: "missing" });
+ });
+
+ test("refuses a base directory that reaches outside the root through a symlink", async () => {
+ const { root } = makeEscapeFixture();
+
+ const res = await getDoc("deep.md", { rootPaths: [root], base: join(root, "linkdir") });
+
+ expect(res.status).toBe(404);
+ expect(await res.text()).not.toContain("SECRET");
+ });
+
+ test("answers 403 for an escaping absolute path whether or not it exists", async () => {
+ const { root, outside } = makeEscapeFixture();
+
+ const existing = await getDoc(join(outside, "secret.md"), { rootPaths: [root] });
+ const absent = await getDoc(join(outside, "absent.md"), { rootPaths: [root] });
+
+ expect(existing.status).toBe(403);
+ expect(absent.status).toBe(403);
+ });
+});
+
+// A root addressed through a symlink is searched under both its spellings, so
+// the one file it finds twice must stay one match.
+describe("symlinked root", () => {
+ test("serves a bare filename from a root given as a symlink", async () => {
+ const real = makeTempDir("plannotator-symroot-real-");
+ const parent = makeTempDir("plannotator-symroot-parent-");
+ const link = join(parent, "docs");
+ writeTempFile(real, "note.md", "note\n");
+ symlinkSync(real, link);
+
+ const res = await getDoc("note.md", { rootPaths: [link] });
+ const data = await res.json() as { markdown?: string };
+
+ expect(res.status).toBe(200);
+ expect(data.markdown).toBe("note\n");
+ });
+});
+
+describe("ambiguous resolution", () => {
+ test.each<{ name: string; file: string; request: string; error: string }>([
+ {
+ name: "code paths report the code shape",
+ file: "dup.ts",
+ request: "dup.ts",
+ error: "Ambiguous path 'dup.ts'",
+ },
+ {
+ name: "document names report the count shape",
+ file: "dup.md",
+ request: "dup.md",
+ error: "Ambiguous filename 'dup.md': found 2 matches",
+ },
+ ])("$name", async ({ file, request, error }) => {
+ const root = makeTempDir("plannotator-ambiguous-");
+ writeTempFile(root, join("a", file), "a");
+ writeTempFile(root, join("b", file), "b");
+
+ const res = await getDoc(request, { rootPaths: [root] });
+ const data = await res.json() as { error?: string; matches?: string[] };
+
+ expect(res.status).toBe(400);
+ expect(data.error).toBe(error);
+ expect(data.matches?.slice().sort()).toEqual([join("a", file), join("b", file)]);
+ });
+});
+
+describe("raw HTML size cap", () => {
+ test("rejects an oversized .html with 413 whether or not a base is given", async () => {
+ const root = makeTempDir("plannotator-html-cap-");
+ writeFileSync(join(root, "huge.html"), `${"x".repeat(2 * 1024 * 1024 + 1)}
`);
+
+ const withoutBase = await getDoc("huge.html", { rootPaths: [root] });
+ const withBase = await getDoc("huge.html", { rootPaths: [root], base: root });
+
+ expect(withoutBase.status).toBe(413);
+ expect(withBase.status).toBe(413);
+ expect((await withBase.json() as { error?: string }).error).toBe("File too large (max 2MB)");
+ });
+});
diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts
index 02549b801..ab4663d7e 100644
--- a/packages/server/reference-handlers.ts
+++ b/packages/server/reference-handlers.ts
@@ -15,21 +15,30 @@ import {
getWorkspaceStatusRelativePaths,
type WorkspaceFileChange,
} from "@plannotator/shared/workspace-status";
-import { parseCodePath } from "@plannotator/shared/code-file";
+import { parseCodePath, type ParsedCodePath } from "@plannotator/shared/code-file";
import { detectObsidianVaults } from "./integrations";
import {
- isAbsoluteUserPath,
- isCodeFilePath,
- resolveCodeFile,
- resolveMarkdownFile,
resolveUserPath,
- isWithinProjectRoot,
getFileBrowserMaxFiles,
warmFileListCache,
getAnnotatableDocRegex,
MAX_ANNOTATABLE_FILE_BYTES,
isAnnotatableTextPath,
} from "@plannotator/shared/resolve-file";
+import {
+ DOC_ACCESS_DENIED,
+ DOC_TOO_LARGE,
+ docResolutionError,
+ getAllowedRootPaths,
+ getTrustedBaseDir,
+ isPathAllowed,
+ relativizeToAllowedRoots,
+ resolveAllowedDocPath,
+ resolveCodeFileInRoots,
+ resolveDocTarget,
+ type DocErrorPayload,
+ type ResolveAllowedDocPathResult,
+} from "@plannotator/shared/doc-resolve";
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
import { disabledSourceSave, type SourceFileSnapshot, type SourceSaveCapability } from "@plannotator/shared/source-save";
import {
@@ -100,123 +109,11 @@ interface HandleDocExistsOptions {
rootPaths?: string[];
}
-type RouteResolveResult =
- | { kind: "found"; path: string }
- | { kind: "not_found"; input: string }
- | { kind: "ambiguous"; input: string; matches: string[] }
- | { kind: "unavailable"; input: string };
-
-function getAllowedRootPaths(options?: { rootPath?: string; rootPaths?: string[] }): string[] {
- const rawRoots = options?.rootPaths?.length
- ? options.rootPaths
- : [options?.rootPath ?? process.cwd()];
- const roots: string[] = [];
- for (const root of rawRoots) {
- if (typeof root !== "string" || root.length === 0) continue;
- const resolved = resolveUserPath(root);
- if (!roots.includes(resolved)) roots.push(resolved);
- }
- return roots.length > 0 ? roots : [resolveUserPath(process.cwd())];
-}
-
-function isWithinAllowedRoots(candidate: string, roots: string[]): boolean {
- return roots.some((root) => isWithinProjectRoot(candidate, root));
-}
-
-function getTrustedBaseDir(base: string | null, roots: string[]): string | null {
- if (!base) return null;
- const resolvedBase = resolveUserPath(base);
- return isWithinAllowedRoots(resolvedBase, roots) ? resolvedBase : null;
-}
-
-export type ResolveAllowedDocPathResult =
- | { kind: "resolved"; path: string }
- | { kind: "denied" };
-
-/**
- * Resolve a client-supplied path the same way /api/doc's base-relative and
- * absolute branches do (see `getTrustedBaseDir` / `isWithinAllowedRoots`
- * above), for callers that need the canonical contained path without
- * reading the file — namely the annotate version endpoints, which derive a
- * history slug from the resolved path rather than trusting a client-supplied
- * slug (a client slug would be a path-traversal vector: `getHistoryDir`
- * joins it into a filesystem path unsanitized).
- */
-export function resolveAllowedDocPath(
- requestedPath: string,
- base: string | null,
- options?: { rootPaths?: string[] },
-): ResolveAllowedDocPathResult {
- const allowedRoots = getAllowedRootPaths(options);
- const resolvedBase = getTrustedBaseDir(base, allowedRoots);
- const candidate = resolveUserPath(requestedPath, resolvedBase ?? undefined);
- return isWithinAllowedRoots(candidate, allowedRoots)
- ? { kind: "resolved", path: candidate }
- : { kind: "denied" };
-}
-
-function relativizeToAllowedRoots(path: string, roots: string[]): string {
- for (const root of roots) {
- const prefix = `${root}/`;
- if (path.startsWith(prefix)) return path.slice(prefix.length);
- if (path === root) return ".";
- }
- return path;
-}
-
-async function resolveCodeFileFromAllowedRoots(
- input: string,
- roots: string[],
- baseDir: string | null,
-): Promise {
- const found = new Set();
- const ambiguous = new Set();
- let unavailable = false;
-
- for (const root of roots) {
- const rootBase = baseDir && isWithinProjectRoot(baseDir, root) ? baseDir : undefined;
- const result = await resolveCodeFile(input, root, rootBase);
- if (result.kind === "found") {
- if (isWithinProjectRoot(result.path, root)) found.add(result.path);
- } else if (result.kind === "ambiguous") {
- for (const match of result.matches) {
- ambiguous.add(match);
- }
- } else if (result.kind === "unavailable") {
- unavailable = true;
- }
- }
+// Re-exported for the annotate version endpoints, which import it from here.
+export { resolveAllowedDocPath, type ResolveAllowedDocPathResult };
- if (found.size === 1) return { kind: "found", path: [...found][0] };
- if (found.size > 1) return { kind: "ambiguous", input, matches: [...found] };
- if (ambiguous.size > 0) return { kind: "ambiguous", input, matches: [...ambiguous] };
- if (unavailable) return { kind: "unavailable", input };
- return { kind: "not_found", input };
-}
-
-function resolveMarkdownFileFromAllowedRoots(input: string, roots: string[]): RouteResolveResult {
- const found = new Set();
- const ambiguous = new Set();
- let unavailable = false;
-
- for (const root of roots) {
- const result = resolveMarkdownFile(input, root);
- if (result.kind === "found") {
- if (isWithinProjectRoot(result.path, root)) found.add(result.path);
- } else if (result.kind === "ambiguous") {
- for (const match of result.matches) {
- ambiguous.add(match);
- }
- } else if (result.kind === "unavailable") {
- unavailable = true;
- }
- }
-
- if (found.size === 1) return { kind: "found", path: [...found][0] };
- if (found.size > 1) return { kind: "ambiguous", input, matches: [...found] };
- if (ambiguous.size > 0) return { kind: "ambiguous", input, matches: [...ambiguous] };
- if (unavailable) return { kind: "unavailable", input };
- return { kind: "not_found", input };
+function errorResponse(payload: DocErrorPayload): Response {
+ return Response.json(payload.body, { status: payload.status });
}
type DocOptionsResult = T & {
@@ -306,6 +203,48 @@ function docJson(data: Record, options?: HandleDocOptions, sour
return Response.json(applyDocOptions(data, options, sourceSnapshot));
}
+/** The render decision is a pure function of resolved path plus `?convert=1`. */
+async function readDocument(path: string, convert: boolean, options: HandleDocOptions): Promise {
+ if (Bun.file(path).size > MAX_ANNOTATABLE_FILE_BYTES) {
+ return errorResponse(DOC_TOO_LARGE);
+ }
+ try {
+ const snapshot = readSourceFileSnapshot(path);
+ if (/\.html?$/i.test(path)) {
+ return convert
+ ? docJson({ markdown: htmlToMarkdown(snapshot.text), filepath: path, isConverted: true, renderAs: "markdown" }, options)
+ : docJson({ rawHtml: snapshot.text, renderAs: "html", filepath: path }, options);
+ }
+ return docJson({ markdown: snapshot.text, filepath: path, renderAs: "markdown" }, options, snapshot);
+ } catch {
+ return Response.json({ error: "Failed to read file" }, { status: 500 });
+ }
+}
+
+async function readCodeFile(path: string, input: string, parsed: ParsedCodePath): Promise {
+ try {
+ const file = Bun.file(path);
+ if (file.size > MAX_ANNOTATABLE_FILE_BYTES) {
+ return errorResponse(DOC_TOO_LARGE);
+ }
+ const contents = await file.text();
+ const displayName = path.split("/").pop() || path;
+ let prerenderedHTML: string | undefined;
+ try {
+ const result = await preloadFile({
+ file: { name: displayName, contents },
+ options: { disableFileHeader: true },
+ });
+ prerenderedHTML = result.prerenderedHTML;
+ } catch {
+ // Fall back to client-side rendering
+ }
+ return Response.json({ codeFile: true, contents, filepath: path, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd });
+ } catch {
+ return Response.json({ error: `File not found: ${input}` }, { status: 404 });
+ }
+}
+
/** Serve a linked markdown document. Resolves absolute, relative, or bare filename paths. */
export async function handleDoc(req: Request, options: HandleDocOptions = {}): Promise {
const url = new URL(req.url);
@@ -321,10 +260,8 @@ export async function handleDoc(req: Request, options: HandleDocOptions = {}): P
void warmFileListCache(root, "code");
}
- // If a base directory is provided, try resolving relative to it first
- // (used by annotate mode to resolve paths relative to the source file).
- const base = url.searchParams.get("base");
- const resolvedBase = getTrustedBaseDir(base, allowedRoots);
+ // A base is only honored when it is itself inside an allowed root.
+ const resolvedBase = getTrustedBaseDir(url.searchParams.get("base"), allowedRoots);
// HTML renders raw by default; `?convert=1` (set by the frontend when the session's
// --markdown preference is on) forces Turndown conversion instead.
const convert = url.searchParams.get("convert") === "1";
@@ -333,166 +270,24 @@ export async function handleDoc(req: Request, options: HandleDocOptions = {}): P
// .xml). Without it, those paths keep the syntax-highlighted code-file
// popout response, so code-file links inside documents are unaffected.
const forceDoc = url.searchParams.get("doc") === "1";
- const docExtensions = getAnnotatableDocRegex();
- const wantsDocRender = (path: string) =>
- docExtensions.test(path) && (forceDoc || !isCodeFilePath(path));
- if (
- resolvedBase &&
- !isAbsoluteUserPath(requestedPath) &&
- wantsDocRender(requestedPath)
- ) {
- const fromBase = resolveUserPath(requestedPath, resolvedBase);
- if (!isWithinAllowedRoots(fromBase, allowedRoots)) {
- return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 });
- }
- try {
- const file = Bun.file(fromBase);
- if (await file.exists()) {
- if (file.size > MAX_ANNOTATABLE_FILE_BYTES) {
- return Response.json({ error: "File too large (max 2MB)" }, { status: 413 });
- }
- const snapshot = readSourceFileSnapshot(fromBase);
- const raw = snapshot.text;
- const isHtml = /\.html?$/i.test(requestedPath);
- if (isHtml && !convert) {
- return docJson({ rawHtml: raw, renderAs: "html", filepath: fromBase }, options);
- }
- const markdown = isHtml ? htmlToMarkdown(raw) : raw;
- return docJson(
- { markdown, filepath: fromBase, isConverted: isHtml, renderAs: "markdown" },
- options,
- isHtml ? undefined : snapshot,
- );
- }
- } catch {
- /* fall through to standard resolution */
- }
- }
-
- // HTML files: resolve directly (not via resolveMarkdownFile which only handles .md/.mdx)
- const projectRoot = allowedRoots[0];
- if (/\.html?$/i.test(requestedPath)) {
- const resolvedHtml = resolveUserPath(requestedPath, resolvedBase || projectRoot);
- if (!isWithinAllowedRoots(resolvedHtml, allowedRoots)) {
- return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 });
- }
- try {
- const file = Bun.file(resolvedHtml);
- if (await file.exists()) {
- const html = await file.text();
- if (!convert) {
- return docJson({ rawHtml: html, renderAs: "html", filepath: resolvedHtml }, options);
- }
- const markdown = htmlToMarkdown(html);
- return docJson({ markdown, filepath: resolvedHtml, isConverted: true, renderAs: "markdown" }, options);
- }
- } catch { /* fall through */ }
- return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
- }
-
- // Code files: try literal resolve first; on miss, fall back to the smart
- // resolver which walks the project for case-insensitive / suffix matches.
- // Skipped when the client asked for doc rendering (`?doc=1`) on an
- // annotatable plain-text path — those fall through to the markdown
- // resolution below and render like .txt.
- if (isCodeFilePath(requestedPath) && !(forceDoc && isAnnotatableTextPath(requestedPath))) {
- const parsed = parseCodePath(requestedPath);
- const cleanPath = parsed.filePath;
- const literalPath = resolveUserPath(cleanPath, resolvedBase || projectRoot);
- const literalAllowed = isWithinAllowedRoots(literalPath, allowedRoots);
-
- let resolvedCode: string | null = null;
- if (literalAllowed) {
- try {
- const file = Bun.file(literalPath);
- if (await file.exists()) resolvedCode = literalPath;
- } catch { /* fall through */ }
- }
-
- if (!resolvedCode) {
- if (isAbsoluteUserPath(cleanPath) && !isWithinAllowedRoots(resolveUserPath(cleanPath), allowedRoots)) {
- return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 });
- }
- const result = await resolveCodeFileFromAllowedRoots(cleanPath, allowedRoots, resolvedBase);
- if (result.kind === "found") {
- resolvedCode = result.path;
- } else if (result.kind === "ambiguous") {
- const relative = result.matches.map((m) => relativizeToAllowedRoots(m, allowedRoots));
- return Response.json(
- { error: `Ambiguous path '${requestedPath}'`, matches: relative },
- { status: 400 },
- );
- } else if (result.kind === "unavailable") {
- return Response.json({ error: `Cannot scan project: ${requestedPath}`, reason: "unavailable" }, { status: 503 });
- } else {
- return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
- }
- if (!isWithinAllowedRoots(resolvedCode, allowedRoots)) {
- return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 });
- }
- }
-
- try {
- const file = Bun.file(resolvedCode);
- if (file.size > MAX_ANNOTATABLE_FILE_BYTES) {
- return Response.json({ error: "File too large (max 2MB)" }, { status: 413 });
- }
- const contents = await file.text();
- const displayName = resolvedCode.split("/").pop() || resolvedCode;
- let prerenderedHTML: string | undefined;
- try {
- const result = await preloadFile({
- file: { name: displayName, contents },
- options: { disableFileHeader: true },
- });
- prerenderedHTML = result.prerenderedHTML;
- } catch {
- // Fall back to client-side rendering
- }
- return Response.json({ codeFile: true, contents, filepath: resolvedCode, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd });
- } catch {
- return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
- }
- }
-
- if (isAbsoluteUserPath(requestedPath) && !isWithinAllowedRoots(resolveUserPath(requestedPath), allowedRoots)) {
- return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 });
- }
- const result = resolveMarkdownFileFromAllowedRoots(requestedPath, allowedRoots);
-
- if (result.kind === "ambiguous") {
- return Response.json(
- {
- error: `Ambiguous filename '${result.input}': found ${result.matches.length} matches`,
- matches: result.matches.map((m) => relativizeToAllowedRoots(m, allowedRoots)),
- },
- { status: 400 },
- );
- }
-
- if (result.kind === "unavailable") {
- return Response.json(
- { error: `Cannot scan project: ${result.input}`, reason: "unavailable" },
- { status: 503 },
- );
- }
- if (result.kind === "not_found") {
- return Response.json(
- { error: `File not found: ${result.input}` },
- { status: 404 },
- );
- }
-
- try {
- if (Bun.file(result.path).size > MAX_ANNOTATABLE_FILE_BYTES) {
- return Response.json({ error: "File too large (max 2MB)" }, { status: 413 });
- }
- const snapshot = readSourceFileSnapshot(result.path);
- return docJson({ markdown: snapshot.text, filepath: result.path, renderAs: "markdown" }, options, snapshot);
- } catch {
- return Response.json({ error: "Failed to read file" }, { status: 500 });
- }
+ const resolution = await resolveDocTarget(requestedPath, {
+ base: resolvedBase,
+ forceDoc,
+ roots: allowedRoots,
+ });
+ // The one authorization site. A named path is gated whether or not it
+ // exists, so an escaping name is denied rather than reported absent.
+ const named = resolution.kind === "file" || resolution.kind === "not_found" ? resolution.path : undefined;
+ if (named !== undefined && !isPathAllowed(named, allowedRoots)) {
+ return errorResponse(DOC_ACCESS_DENIED);
+ }
+ if (resolution.kind !== "file") {
+ return errorResponse(docResolutionError(resolution, allowedRoots));
+ }
+ return resolution.render === "code"
+ ? readCodeFile(resolution.path, requestedPath, resolution.code)
+ : readDocument(resolution.path, convert, options);
}
/**
@@ -529,14 +324,9 @@ export async function handleDocExists(req: Request, options?: HandleDocExistsOpt
await Promise.all(
(paths as string[]).map(async (p) => {
- const cleanP = parseCodePath(p).filePath;
- if (isAbsoluteUserPath(cleanP) && !isWithinAllowedRoots(resolveUserPath(cleanP), allowedRoots)) {
- results[p] = { status: "missing" };
- return;
- }
- const r = await resolveCodeFileFromAllowedRoots(cleanP, allowedRoots, baseDir);
+ const r = await resolveCodeFileInRoots(parseCodePath(p).filePath, allowedRoots, baseDir);
if (r.kind === "found") {
- results[p] = isWithinAllowedRoots(r.path, allowedRoots)
+ results[p] = isPathAllowed(r.path, allowedRoots)
? { status: "found", resolved: r.path }
: { status: "missing" };
} else if (r.kind === "ambiguous") {
diff --git a/packages/shared/doc-resolve.test.ts b/packages/shared/doc-resolve.test.ts
new file mode 100644
index 000000000..213c6c4cc
--- /dev/null
+++ b/packages/shared/doc-resolve.test.ts
@@ -0,0 +1,74 @@
+/**
+ * The `/api/doc` containment gate. Normalization has to be two-sided: macOS
+ * `tmpdir()` is a symlink, so
+ * realpath containment against a non-realpathed root would deny legitimate
+ * reads for anyone whose root sits under one.
+ */
+import { afterEach, describe, expect, test } from "bun:test";
+import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { getAllowedRootPaths, isPathAllowed } from "./doc-resolve";
+
+const tempDirs: string[] = [];
+
+function makeTempDir(prefix: string): string {
+ const dir = mkdtempSync(join(tmpdir(), prefix));
+ tempDirs.push(dir);
+ return dir;
+}
+
+afterEach(() => {
+ for (const dir of tempDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+describe("isPathAllowed", () => {
+ test("serves a file inside the root and denies one outside it", () => {
+ const root = makeTempDir("plannotator-gate-root-");
+ const outside = makeTempDir("plannotator-gate-outside-");
+ writeFileSync(join(root, "note.md"), "note\n");
+ writeFileSync(join(outside, "secret.md"), "secret\n");
+ const roots = getAllowedRootPaths({ rootPaths: [root] });
+
+ expect(isPathAllowed(join(root, "note.md"), roots)).toBe(true);
+ expect(isPathAllowed(join(outside, "secret.md"), roots)).toBe(false);
+ });
+
+ test("denies a symlink that escapes the root, and a file reached through a symlinked directory", () => {
+ const root = makeTempDir("plannotator-gate-link-root-");
+ const outside = makeTempDir("plannotator-gate-link-outside-");
+ writeFileSync(join(outside, "secret.md"), "secret\n");
+ symlinkSync(join(outside, "secret.md"), join(root, "link.md"));
+ symlinkSync(outside, join(root, "linkdir"));
+ const roots = getAllowedRootPaths({ rootPaths: [root] });
+
+ expect(isPathAllowed(join(root, "link.md"), roots)).toBe(false);
+ expect(isPathAllowed(join(root, "linkdir", "secret.md"), roots)).toBe(false);
+ });
+
+ test("allows a symlinked root under either spelling", () => {
+ const realFolder = makeTempDir("plannotator-gate-real-");
+ const linkParent = makeTempDir("plannotator-gate-linkparent-");
+ const linkFolder = join(linkParent, "docs");
+ writeFileSync(join(realFolder, "note.md"), "note\n");
+ symlinkSync(realFolder, linkFolder);
+ const roots = getAllowedRootPaths({ rootPaths: [linkFolder] });
+
+ expect(isPathAllowed(join(linkFolder, "note.md"), roots)).toBe(true);
+ expect(isPathAllowed(realpathSync(join(realFolder, "note.md")), roots)).toBe(true);
+ });
+
+ test("judges a path whose leaf does not exist by its deepest existing ancestor", () => {
+ const root = makeTempDir("plannotator-gate-missing-root-");
+ const outside = makeTempDir("plannotator-gate-missing-outside-");
+ symlinkSync(outside, join(root, "linkdir"));
+ mkdirSync(join(root, "real"), { recursive: true });
+ const roots = getAllowedRootPaths({ rootPaths: [root] });
+
+ expect(isPathAllowed(join(root, "real", "absent.md"), roots)).toBe(true);
+ expect(isPathAllowed(join(root, "absent", "deeper", "absent.md"), roots)).toBe(true);
+ expect(isPathAllowed(join(root, "linkdir", "absent.md"), roots)).toBe(false);
+ });
+});
diff --git a/packages/shared/doc-resolve.ts b/packages/shared/doc-resolve.ts
new file mode 100644
index 000000000..d241e737c
--- /dev/null
+++ b/packages/shared/doc-resolve.ts
@@ -0,0 +1,338 @@
+/**
+ * Document resolution and containment for `/api/doc` and `/api/doc/exists`,
+ * shared by the Bun and node:http servers.
+ *
+ * Resolving which file a path names is separated from reading it so
+ * authorization sits on one seam: every reachable path comes from
+ * `resolveDocTarget`, and `isPathAllowed` alone decides whether it is served.
+ * Resolution may `stat`; it never reads contents.
+ */
+
+import { realpathSync, statSync } from "fs";
+import { basename, dirname, join } from "path";
+import { parseCodePath, type ParsedCodePath } from "./code-file";
+import {
+ getAnnotatableDocRegex,
+ isAbsoluteUserPath,
+ isAnnotatableTextPath,
+ isCodeFilePath,
+ isWithinProjectRoot,
+ resolveCodeFile,
+ resolveMarkdownFile,
+ resolveUserPath,
+} from "./resolve-file";
+
+/** How a resolved file is rendered: an annotatable document, or a code-file popout. */
+export type DocRender = "document" | "code";
+
+export type DocResolution =
+ | { kind: "file"; path: string; render: "document" }
+ | { kind: "file"; path: string; render: "code"; code: ParsedCodePath }
+ // `path` is set when the input named one path that does not exist; it is
+ // gated like a found path so an escaping name is denied, not reported absent.
+ | { kind: "not_found"; input: string; path?: string }
+ | { kind: "ambiguous"; input: string; matches: string[]; render: DocRender }
+ | { kind: "unavailable"; input: string };
+
+export interface DocResolveOptions {
+ /** Base directory for relative paths, already vetted by `getTrustedBaseDir`. */
+ base?: string | null;
+ /** `?doc=1`. Renders annotatable plain text for extensions that overlap code files. */
+ forceDoc?: boolean;
+ roots: string[];
+}
+
+export interface DocErrorPayload {
+ status: number;
+ body: Record;
+}
+
+export const DOC_ACCESS_DENIED: DocErrorPayload = {
+ status: 403,
+ body: { error: "Access denied: path is outside project root" },
+};
+
+export const DOC_TOO_LARGE: DocErrorPayload = {
+ status: 413,
+ body: { error: "File too large (max 2MB)" },
+};
+
+export function getAllowedRootPaths(options?: { rootPath?: string; rootPaths?: string[] }): string[] {
+ const rawRoots = options?.rootPaths?.length
+ ? options.rootPaths
+ : [options?.rootPath ?? process.cwd()];
+ const roots: string[] = [];
+ const addRoot = (root: string) => {
+ const resolved = resolveUserPath(root);
+ if (!resolved) return;
+ if (!roots.includes(resolved)) roots.push(resolved);
+ // A root reachable through a symlink contributes both spellings, or a
+ // request naming either one fails the half it does not match.
+ const real = realpathAllowingMissingLeaf(resolved);
+ if (real && !roots.includes(real)) roots.push(real);
+ };
+ for (const root of rawRoots) {
+ if (typeof root !== "string" || root.length === 0) continue;
+ addRoot(root);
+ }
+ if (roots.length === 0) addRoot(process.cwd());
+ return roots;
+}
+
+/**
+ * Realpath the deepest existing ancestor and re-join the rest, so a candidate
+ * whose leaf need not exist still resolves through symlinked parents. Any
+ * failure other than a missing entry fails closed.
+ */
+function realpathAllowingMissingLeaf(candidate: string): string | null {
+ if (!candidate) return null;
+ let current = candidate;
+ const missing: string[] = [];
+ for (;;) {
+ try {
+ const real = realpathSync(current);
+ return missing.length > 0 ? join(real, ...missing) : real;
+ } catch (error) {
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) return null;
+ const parent = dirname(current);
+ if (parent === current) return null;
+ missing.unshift(basename(current));
+ current = parent;
+ }
+ }
+}
+
+/**
+ * The containment gate: a path must sit inside an allowed root both as written
+ * and after symlink resolution. Lexical containment alone reads a symlink
+ * planted under a root through to whatever it points at.
+ *
+ * Path-based, so it does not survive a local writer swapping the path between
+ * this check and the reader's open.
+ */
+export function isPathAllowed(candidate: string, roots: string[]): boolean {
+ if (!candidate) return false;
+ if (!roots.some((root) => isWithinProjectRoot(candidate, root))) return false;
+ const real = realpathAllowingMissingLeaf(candidate);
+ if (!real) return false;
+ return roots.some((root) => isWithinProjectRoot(real, root));
+}
+
+export function getTrustedBaseDir(base: string | null | undefined, roots: string[]): string | null {
+ if (!base) return null;
+ const resolvedBase = resolveUserPath(base);
+ return isPathAllowed(resolvedBase, roots) ? resolvedBase : null;
+}
+
+export type ResolveAllowedDocPathResult =
+ | { kind: "resolved"; path: string }
+ | { kind: "denied" };
+
+/**
+ * Resolve a path through the same gate `/api/doc` uses, for callers needing the
+ * canonical contained path without reading the file. The annotate version
+ * endpoints derive a history slug from it, since the history dir lookup joins
+ * the slug into a path unsanitized.
+ */
+export function resolveAllowedDocPath(
+ requestedPath: string,
+ base: string | null,
+ options?: { rootPaths?: string[] },
+): ResolveAllowedDocPathResult {
+ const allowedRoots = getAllowedRootPaths(options);
+ const resolvedBase = getTrustedBaseDir(base, allowedRoots);
+ const candidate = resolveUserPath(requestedPath, resolvedBase ?? undefined);
+ return isPathAllowed(candidate, allowedRoots)
+ ? { kind: "resolved", path: candidate }
+ : { kind: "denied" };
+}
+
+export function relativizeToAllowedRoots(path: string, roots: string[]): string {
+ for (const root of roots) {
+ const prefix = `${root}/`;
+ if (path.startsWith(prefix)) return path.slice(prefix.length);
+ if (path === root) return ".";
+ }
+ return path;
+}
+
+function isReadableFile(path: string): boolean {
+ try {
+ return statSync(path).isFile();
+ } catch {
+ return false;
+ }
+}
+
+// A symlinked root is searched under both spellings; the same file found twice
+// that way is one match, not an ambiguity.
+function matchKey(path: string): string {
+ if (!isAbsoluteUserPath(path)) return path;
+ return realpathAllowingMissingLeaf(path) ?? path;
+}
+
+export type RootSearchResult =
+ | { kind: "found"; path: string }
+ | { kind: "not_found" }
+ | { kind: "ambiguous"; matches: string[] }
+ | { kind: "unavailable" };
+
+function collectRootSearch(
+ found: Map,
+ ambiguous: Map,
+ results: readonly { root: string; result: ReturnType }[],
+): boolean {
+ let unavailable = false;
+ for (const { root, result } of results) {
+ if (result.kind === "found") {
+ if (!isWithinProjectRoot(result.path, root)) continue;
+ const key = matchKey(result.path);
+ if (!found.has(key)) found.set(key, result.path);
+ } else if (result.kind === "ambiguous") {
+ for (const match of result.matches) {
+ const key = matchKey(match);
+ if (!ambiguous.has(key)) ambiguous.set(key, match);
+ }
+ } else if (result.kind === "unavailable") {
+ unavailable = true;
+ }
+ }
+ return unavailable;
+}
+
+function summarizeRootSearch(
+ found: Map,
+ ambiguous: Map,
+ unavailable: boolean,
+): RootSearchResult {
+ if (found.size === 1) return { kind: "found", path: [...found.values()][0] };
+ if (found.size > 1) return { kind: "ambiguous", matches: [...found.values()] };
+ if (ambiguous.size > 0) return { kind: "ambiguous", matches: [...ambiguous.values()] };
+ if (unavailable) return { kind: "unavailable" };
+ return { kind: "not_found" };
+}
+
+export async function resolveCodeFileInRoots(
+ input: string,
+ roots: string[],
+ baseDir: string | null,
+): Promise {
+ const results = await Promise.all(
+ roots.map(async (root) => {
+ const rootBase = baseDir && isWithinProjectRoot(baseDir, root) ? baseDir : undefined;
+ return { root, result: await resolveCodeFile(input, root, rootBase) };
+ }),
+ );
+ const found = new Map();
+ const ambiguous = new Map();
+ const unavailable = collectRootSearch(found, ambiguous, results);
+ return summarizeRootSearch(found, ambiguous, unavailable);
+}
+
+function searchMarkdownFile(input: string, roots: string[]): RootSearchResult {
+ const results = roots.map((root) => ({ root, result: resolveMarkdownFile(input, root) }));
+ const found = new Map();
+ const ambiguous = new Map();
+ const unavailable = collectRootSearch(found, ambiguous, results);
+ return summarizeRootSearch(found, ambiguous, unavailable);
+}
+
+/** The returned path is not authorized here. */
+export async function resolveDocTarget(
+ requestedPath: string,
+ options: DocResolveOptions,
+): Promise {
+ const { roots } = options;
+ const base = options.base ?? null;
+ const projectRoot = roots[0];
+ const docExtensions = getAnnotatableDocRegex();
+ const wantsDocRender = (path: string) =>
+ docExtensions.test(path) && (options.forceDoc || !isCodeFilePath(path));
+
+ // Relative to the source document's own directory (annotate sibling links).
+ if (base && !isAbsoluteUserPath(requestedPath) && wantsDocRender(requestedPath)) {
+ const fromBase = resolveUserPath(requestedPath, base);
+ // An escaping name means only itself, so it never falls through.
+ if (!roots.some((root) => isWithinProjectRoot(fromBase, root))) {
+ return { kind: "not_found", input: requestedPath, path: fromBase };
+ }
+ if (isReadableFile(fromBase)) {
+ return { kind: "file", path: fromBase, render: "document" };
+ }
+ }
+
+ // resolveMarkdownFile only handles plain text, so HTML resolves directly.
+ if (/\.html?$/i.test(requestedPath)) {
+ const resolvedHtml = resolveUserPath(requestedPath, base || projectRoot);
+ return isReadableFile(resolvedHtml)
+ ? { kind: "file", path: resolvedHtml, render: "document" }
+ : { kind: "not_found", input: requestedPath, path: resolvedHtml };
+ }
+
+ // Literal path first; on a miss the smart resolver walks the roots for
+ // case-insensitive and suffix matches.
+ if (isCodeFilePath(requestedPath) && !(options.forceDoc && isAnnotatableTextPath(requestedPath))) {
+ const parsed = parseCodePath(requestedPath);
+ const cleanPath = parsed.filePath;
+ const literalPath = resolveUserPath(cleanPath, base || projectRoot);
+ if (roots.some((root) => isWithinProjectRoot(literalPath, root)) && isReadableFile(literalPath)) {
+ return { kind: "file", path: literalPath, render: "code", code: parsed };
+ }
+ if (isAbsoluteUserPath(cleanPath)) {
+ const absolutePath = resolveUserPath(cleanPath);
+ return isReadableFile(absolutePath)
+ ? { kind: "file", path: absolutePath, render: "code", code: parsed }
+ : { kind: "not_found", input: requestedPath, path: absolutePath };
+ }
+ const search = await resolveCodeFileInRoots(cleanPath, roots, base);
+ if (search.kind === "found") return { kind: "file", path: search.path, render: "code", code: parsed };
+ if (search.kind === "ambiguous") {
+ return { kind: "ambiguous", input: requestedPath, matches: search.matches, render: "code" };
+ }
+ if (search.kind === "unavailable") return { kind: "unavailable", input: requestedPath };
+ return { kind: "not_found", input: requestedPath };
+ }
+
+ // An absolute path names one file, so it skips the fuzzy search.
+ if (isAbsoluteUserPath(requestedPath)) {
+ const trimmed = requestedPath.trim();
+ const absolutePath = resolveUserPath(trimmed);
+ return isAnnotatableTextPath(trimmed) && isReadableFile(absolutePath)
+ ? { kind: "file", path: absolutePath, render: "document" }
+ : { kind: "not_found", input: requestedPath, path: absolutePath };
+ }
+
+ const search = searchMarkdownFile(requestedPath, roots);
+ if (search.kind === "found") return { kind: "file", path: search.path, render: "document" };
+ if (search.kind === "ambiguous") {
+ return { kind: "ambiguous", input: requestedPath, matches: search.matches, render: "document" };
+ }
+ if (search.kind === "unavailable") return { kind: "unavailable", input: requestedPath };
+ return { kind: "not_found", input: requestedPath };
+}
+
+/** The response both transports send for a non-`file` resolution. */
+export function docResolutionError(
+ resolution: Exclude,
+ roots: string[],
+): DocErrorPayload {
+ if (resolution.kind === "ambiguous") {
+ const matches = resolution.matches.map((match) => relativizeToAllowedRoots(match, roots));
+ return resolution.render === "code"
+ ? { status: 400, body: { error: `Ambiguous path '${resolution.input}'`, matches } }
+ : {
+ status: 400,
+ body: {
+ error: `Ambiguous filename '${resolution.input}': found ${resolution.matches.length} matches`,
+ matches,
+ },
+ };
+ }
+ if (resolution.kind === "unavailable") {
+ return {
+ status: 503,
+ body: { error: `Cannot scan project: ${resolution.input}`, reason: "unavailable" },
+ };
+ }
+ return { status: 404, body: { error: `File not found: ${resolution.input}` } };
+}
diff --git a/packages/shared/package.json b/packages/shared/package.json
index f9d05f5b2..f6422486c 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -37,6 +37,7 @@
"./favicon": "./favicon.ts",
"./code-file": "./code-file.ts",
"./resolve-file": "./resolve-file.ts",
+ "./doc-resolve": "./doc-resolve.ts",
"./annotate-reference-roots-node": "./annotate-reference-roots-node.ts",
"./extract-code-paths": "./extract-code-paths.ts",
"./external-annotation": "./external-annotation.ts",
diff --git a/packages/shared/storage.test.ts b/packages/shared/storage.test.ts
new file mode 100644
index 000000000..7eccce326
--- /dev/null
+++ b/packages/shared/storage.test.ts
@@ -0,0 +1,38 @@
+import { describe, test, expect, afterEach } from "bun:test";
+import { readFileSync } from "node:fs";
+
+// Imported statically and BEFORE any PLANNOTATOR_DATA_DIR is set: this module's
+// import-time side effects used to freeze the data directory, so a later env
+// change was silently ignored while every other consumer resolved it live.
+import { getHistoryDir, saveToHistory } from "./storage";
+
+import { createTestEnvironment } from "../../tests/helpers/environment";
+
+const env = createTestEnvironment(["PLANNOTATOR_DATA_DIR"], "plannotator-storage-");
+
+afterEach(() => env.restore());
+
+describe("storage data directory", () => {
+ test("resolves PLANNOTATOR_DATA_DIR set after import", () => {
+ env.reset();
+ const dataDir = env.makeTempDir();
+ process.env.PLANNOTATOR_DATA_DIR = dataDir;
+
+ expect(getHistoryDir("proj", "slug")).toBe(`${dataDir}/history/proj/slug`);
+
+ const saved = saveToHistory("proj", "slug", "# Plan\n");
+ expect(saved.path).toBe(`${dataDir}/history/proj/slug/001.md`);
+ expect(readFileSync(saved.path, "utf-8")).toBe("# Plan\n");
+ });
+
+ test("follows a later change to PLANNOTATOR_DATA_DIR", () => {
+ env.reset();
+ const first = env.makeTempDir();
+ process.env.PLANNOTATOR_DATA_DIR = first;
+ expect(getHistoryDir("proj", "slug")).toBe(`${first}/history/proj/slug`);
+
+ const second = env.makeTempDir();
+ process.env.PLANNOTATOR_DATA_DIR = second;
+ expect(getHistoryDir("proj", "slug")).toBe(`${second}/history/proj/slug`);
+ });
+});
diff --git a/packages/shared/storage.ts b/packages/shared/storage.ts
index cc4e12f66..5c3662043 100644
--- a/packages/shared/storage.ts
+++ b/packages/shared/storage.ts
@@ -13,8 +13,6 @@ import { sanitizeTag } from "./project";
import { resolveUserPath } from "./resolve-file";
import { getPlannotatorDataDir } from "./data-dir";
-const DATA_DIR = getPlannotatorDataDir();
-
/**
* Get the plan storage directory, creating it if needed.
* Cross-platform: uses os.homedir() for Windows/macOS/Linux compatibility.
@@ -26,7 +24,7 @@ export function getPlanDir(customPath?: string | null): string {
if (customPath?.trim()) {
planDir = resolveUserPath(customPath);
} else {
- planDir = join(DATA_DIR, "plans");
+ planDir = join(getPlannotatorDataDir(), "plans");
}
mkdirSync(planDir, { recursive: true });
@@ -195,7 +193,7 @@ export function readArchivedPlan(filename: string, customPath?: string | null):
* Not affected by the customPath setting (that only affects decision saves).
*/
export function getHistoryDir(project: string, slug: string): string {
- const historyDir = join(DATA_DIR, "history", project, slug);
+ const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
mkdirSync(historyDir, { recursive: true });
return historyDir;
}
@@ -294,7 +292,7 @@ export function getPlanVersion(
slug: string,
version: number
): string | null {
- const historyDir = join(DATA_DIR, "history", project, slug);
+ const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
const fileName = `${String(version).padStart(3, "0")}.md`;
const filePath = join(historyDir, fileName);
@@ -314,7 +312,7 @@ export function getPlanVersionPath(
slug: string,
version: number
): string | null {
- const historyDir = join(DATA_DIR, "history", project, slug);
+ const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
const fileName = `${String(version).padStart(3, "0")}.md`;
const filePath = join(historyDir, fileName);
return existsSync(filePath) ? filePath : null;
@@ -325,7 +323,7 @@ export function getPlanVersionPath(
* Returns 0 if the directory doesn't exist.
*/
export function getVersionCount(project: string, slug: string): number {
- const historyDir = join(DATA_DIR, "history", project, slug);
+ const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
try {
const entries = readdirSync(historyDir);
return entries.filter((e) => /^\d+\.md$/.test(e)).length;
@@ -342,7 +340,7 @@ export function listVersions(
project: string,
slug: string
): Array<{ version: number; timestamp: string }> {
- const historyDir = join(DATA_DIR, "history", project, slug);
+ const historyDir = join(getPlannotatorDataDir(), "history", project, slug);
try {
const entries = readdirSync(historyDir);
const versions: Array<{ version: number; timestamp: string }> = [];
@@ -372,7 +370,7 @@ export function listVersions(
export function listProjectPlans(
project: string
): Array<{ slug: string; versions: number; lastModified: string }> {
- const projectDir = join(DATA_DIR, "history", project);
+ const projectDir = join(getPlannotatorDataDir(), "history", project);
try {
const entries = readdirSync(projectDir, { withFileTypes: true });
const plans: Array<{ slug: string; versions: number; lastModified: string }> = [];