mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
271fcefded
* fix(server): live-proxy injection and config write hardening Four confirmed pre-release QA findings, each with a test that fails on the pre-fix source. live-proxy: the HTML injector scanned for head markers with no notion of comments, so a codegen banner naming <head> before the real tag captured the bridge script into a dead comment span: never executed, annotation silently broken, no warning. The scanner now skips comments and the '>'-terminated markup-declaration / bogus-comment spans (doctype, CDATA-ish, <?...>) before matching, inside the same chunk-boundary state machine. Raw-text element contents are still not tracked; that limit and its degraded outcome are documented in the source. live-proxy: new URL(req.url) ran before Host validation, so a Host-less HTTP/1.0 request threw and served Bun's internal debug page with a stack trace. Host validation now runs first, and URL construction takes the same 403 path on failure. live-proxy: the text/html content-type test was case-sensitive, so a valid TEXT/HTML response skipped injection and the framing rewrites. config: saveConfig was an unlocked read-merge-write, so two processes sharing a data dir dropped each other's keys while both reported success. The read-merge-write now runs under an O_EXCL advisory lockfile with a bounded wait and stale takeover, degrading to the old behavior with a warning rather than ever hanging, and the write itself is temp+rename so lock-free readers cannot observe a torn file. Also consolidates the duplicated agent-terminal side predicate onto the single definition in @plannotator/core. * fix(annotate): give live app sessions their own draft slot mode "annotate-app" resolves markdown to "" by construction (the page lives behind the proxy, not in a string the server holds), and the autosave draft key was contentHash of that body. Every live session on the machine therefore collapsed to the one hash of the empty string and shared a single draft slot: two sessions against different dev servers read and overwrote each other's in-progress annotations, deterministically. A live session's identity is its target, exactly as a folder session's identity is its folder path, so the key is now derived from the target URL (normalized through the URL parser so the same dev server recovers its draft when spelled with or without a trailing slash). Classic file and folder keying is untouched. Pi has no live app mode (no annotate-app, liveApp or live-proxy outside its vendored generated/ tree), so there is nothing to mirror there.
69 lines
2.5 KiB
TypeScript
69 lines
2.5 KiB
TypeScript
/**
|
|
* The Pi server shares one ~/.plannotator data dir with whatever Bun-runtime
|
|
* Plannotator session the user has open, and settles POST /api/config through
|
|
* the SAME saveConfig — but through the vendored copy in generated/, not the
|
|
* package under packages/shared. This asserts the write serialization is
|
|
* actually present on that side of the vendor boundary, so a vendor.sh rule
|
|
* that stopped copying it (or a Pi-only reimplementation) fails here rather
|
|
* than in a user's config file.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
loadConfig,
|
|
saveConfig,
|
|
getConfigLockPath,
|
|
isAgentTerminalSide,
|
|
__setConfigSaveMergeWindowHookForTest,
|
|
__setConfigLockTimingsForTest,
|
|
} from "./generated/config.ts";
|
|
|
|
describe("vendored config write path", () => {
|
|
const originalDataDir = process.env.PLANNOTATOR_DATA_DIR;
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), "plannotator-pi-config-"));
|
|
process.env.PLANNOTATOR_DATA_DIR = tempDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
__setConfigSaveMergeWindowHookForTest(null);
|
|
__setConfigLockTimingsForTest(null);
|
|
if (originalDataDir !== undefined) {
|
|
process.env.PLANNOTATOR_DATA_DIR = originalDataDir;
|
|
} else {
|
|
delete process.env.PLANNOTATOR_DATA_DIR;
|
|
}
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("saveConfig holds the same advisory lock across its read-merge-write", () => {
|
|
const lockPath = getConfigLockPath();
|
|
expect(lockPath).toBe(join(tempDir, "config.json.lock"));
|
|
|
|
let lockedDuringMerge = false;
|
|
__setConfigSaveMergeWindowHookForTest(() => {
|
|
lockedDuringMerge = existsSync(lockPath);
|
|
});
|
|
saveConfig({ agentTerminalSide: "right" });
|
|
|
|
expect(lockedDuringMerge).toBe(true);
|
|
expect(existsSync(lockPath)).toBe(false);
|
|
expect(loadConfig().agentTerminalSide).toBe("right");
|
|
});
|
|
|
|
test("the side guard the annotate endpoint uses accepts exactly the core sides", () => {
|
|
// serverAnnotate.ts gates POST /api/config on this; it now delegates to
|
|
// the single definition vendored from @plannotator/core.
|
|
expect(isAgentTerminalSide("left")).toBe(true);
|
|
expect(isAgentTerminalSide("right")).toBe(true);
|
|
expect(isAgentTerminalSide("hidden")).toBe(true);
|
|
expect(isAgentTerminalSide("top")).toBe(false);
|
|
expect(isAgentTerminalSide(undefined)).toBe(false);
|
|
});
|
|
});
|